@oxecli/oxe 1.0.5 → 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,15 +163,19 @@ 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 || {};
172
+ process.stdout.write("\n");
169
173
  process.stdout.write(markupToAnsi(`[green]✓ Oxe API Key verified[/green] [dim](${String(key_data["name"] ?? "Desktop")})[/dim]`) + "\n");
170
174
  process.env.OXE_API_KEY = api_key;
171
175
  break;
172
176
  }
173
177
  else {
178
+ process.stdout.write("\n");
174
179
  process.stdout.write(`\x1b[31mAuthentication Error:\x1b[0m ${validation.error}\n`);
175
180
  process.stdout.write("\n");
176
181
  api_key = "";
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 = [];
@@ -76,7 +56,8 @@ export class InferenceEngine {
76
56
  this.client = new OpenAI({
77
57
  apiKey: config["api_key"],
78
58
  baseURL: config["base_url"],
79
- timeout: config["timeout"] ?? 120,
59
+ // OpenAI SDK `timeout` is in MILLISECONDS; config stores seconds.
60
+ timeout: (config["timeout"] ?? 120) * 1000,
80
61
  maxRetries: config["max_retries"] ?? 2,
81
62
  });
82
63
  this.temperature = config["temperature"] ?? null;
@@ -136,28 +117,70 @@ export class InferenceEngine {
136
117
  incompleteReason(response) {
137
118
  return response?.incomplete_details?.reason ?? null;
138
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
+ }
139
128
  async streamOnce(inputItems, stats, story, previousResponseId) {
140
129
  let content = "";
141
130
  let committedLen = 0;
142
131
  let pending = "";
143
132
  let sawReasoning = false;
144
133
  let status = null;
145
- const thinkingStart = Date.now();
146
- const workStatus = new Status("Working for `0s`");
147
- 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;
148
142
  let response = null;
149
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
+ };
150
169
  const finishThinking = (report) => {
170
+ if (thinkingStart === null)
171
+ return;
172
+ const elapsed = (Date.now() - thinkingStart) / 1000;
151
173
  if (status) {
152
174
  status.stop();
153
175
  status = null;
154
176
  }
155
177
  if (report) {
156
- const elapsed = (Date.now() - thinkingStart) / 1000;
157
178
  const t = tickDuration(elapsed);
158
179
  story.push({ type: "thought", text: `Thought for ${t}` });
159
- process.stdout.write("\n" + mutedMarkdown(`Thought for ${t}`) + "\n");
180
+ process.stdout.write(mutedMarkdown(`Thought for ${t}`) + "\n\n");
181
+ this.workActive = false;
160
182
  }
183
+ thinkingStart = null;
161
184
  };
162
185
  try {
163
186
  const kwargs = {
@@ -189,13 +212,14 @@ export class InferenceEngine {
189
212
  if (etype === "response.reasoning_text.delta" ||
190
213
  etype === "response.reasoning.summary.delta") {
191
214
  sawReasoning = true;
192
- if (!status) {
193
- status = new Status("Thinking for `0s`");
194
- status.start();
215
+ silenceWorking();
216
+ if (thinkingStart === null) {
217
+ thinkingStart = Date.now();
218
+ status = new Spinner();
219
+ status.start("Thinking for `0s`");
195
220
  }
196
221
  else {
197
- const elapsed = (Date.now() - thinkingStart) / 1000;
198
- status.update(`Thinking for ${tickDuration(elapsed)}`);
222
+ status?.update(`Thinking for ${tickDuration((Date.now() - thinkingStart) / 1000)}`);
199
223
  }
200
224
  }
201
225
  else if (etype === "response.reasoning_text.done" ||
@@ -203,6 +227,7 @@ export class InferenceEngine {
203
227
  finishThinking(true);
204
228
  }
205
229
  else if (etype === "response.output_text.delta") {
230
+ reportWorking();
206
231
  finishThinking(true);
207
232
  content += event.delta;
208
233
  pending += event.delta;
@@ -223,8 +248,13 @@ export class InferenceEngine {
223
248
  }
224
249
  }
225
250
  finally {
251
+ clearInterval(workTimer);
226
252
  workStatus.stop();
227
- 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();
228
258
  if (content.slice(committedLen))
229
259
  printAiChunk(content.slice(committedLen));
230
260
  }
@@ -331,13 +361,17 @@ export class InferenceEngine {
331
361
  if (!this.contextOverBudget(conversation))
332
362
  return [conversation, false];
333
363
  const started = Date.now();
334
- const comp = new Status("Compacting conversation for `0s`");
335
- 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);
336
369
  let compacted;
337
370
  try {
338
371
  compacted = await this.compactHistory(conversation);
339
372
  }
340
373
  finally {
374
+ clearInterval(compTimer);
341
375
  comp.stop();
342
376
  }
343
377
  if (!compacted)
@@ -345,11 +379,14 @@ export class InferenceEngine {
345
379
  persistCompactionSummary(inputItems, compacted[0]);
346
380
  const text = `Compacted conversation in ${tickDuration((Date.now() - started) / 1000)}`;
347
381
  story.push({ type: "compacted", text });
348
- process.stdout.write("\n" + mutedMarkdown(text) + "\n");
382
+ this.workActive = false;
383
+ process.stdout.write(mutedMarkdown(text) + "\n");
384
+ process.stdout.write("\n");
349
385
  return [compacted, true];
350
386
  }
351
387
  async executeQuery(userPrompt, inputItems, story, pasteSpans) {
352
388
  this.inQuery = true;
389
+ hideCursor();
353
390
  inputItems.push({
354
391
  role: "user",
355
392
  content: userPrompt,
@@ -383,7 +420,6 @@ export class InferenceEngine {
383
420
  };
384
421
  let prevId = null;
385
422
  let pending = conversation;
386
- let resetInQuery = true;
387
423
  try {
388
424
  for (let step = 0; step < max_agent_steps; step++) {
389
425
  let text = "";
@@ -417,13 +453,11 @@ export class InferenceEngine {
417
453
  respId = res.responseId;
418
454
  }
419
455
  catch (err2) {
420
- process.stdout.write("\n");
421
456
  renderErrorPanel(`API Error: ${err2}`);
422
457
  return;
423
458
  }
424
459
  }
425
460
  else {
426
- process.stdout.write("\n");
427
461
  renderErrorPanel(`API Error: ${err}`);
428
462
  return;
429
463
  }
@@ -457,7 +491,7 @@ export class InferenceEngine {
457
491
  continue;
458
492
  }
459
493
  dropRetryPrompts(conversation, inputItems, retryPrompts);
460
- process.stdout.write("\n" + aiMarkdown(emptyMessage) + "\n");
494
+ process.stdout.write(aiMarkdown(emptyMessage) + "\n");
461
495
  process.stdout.write("\n" + summary() + "\n");
462
496
  return;
463
497
  }
@@ -471,6 +505,7 @@ export class InferenceEngine {
471
505
  if (text.trim())
472
506
  process.stdout.write("\n");
473
507
  for (const c of calls) {
508
+ this.workActive = false;
474
509
  const started = formatToolAction(c.name, c.arguments, "started");
475
510
  process.stdout.write(`\x1b[2m${started}\x1b[0m\n`);
476
511
  if (c.name === "edit_file" || c.name === "write_file")
@@ -495,9 +530,11 @@ export class InferenceEngine {
495
530
  pending.push(outputItem);
496
531
  }
497
532
  }
498
- process.stdout.write("\n\x1b[1;33m⚠ Max tool-call iterations reached for this turn.\x1b[0m\n" +
499
- "\x1b[33mThe work so far is saved. If you want the agent to keep going, type " +
500
- "[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");
501
538
  if (retryPrompts.length)
502
539
  dropRetryPrompts(conversation, inputItems, retryPrompts);
503
540
  stripOrphanCalls(conversation);
@@ -507,11 +544,11 @@ export class InferenceEngine {
507
544
  if (err?.message === "interrupt" || err?.message === "eof") {
508
545
  throw err;
509
546
  }
510
- process.stdout.write("\n");
511
547
  renderErrorPanel(`Runtime Exception: ${err}`);
512
548
  }
513
549
  finally {
514
550
  this.inQuery = false;
551
+ showCursor();
515
552
  if (stats["tokens"] > 0 && this.keyData) {
516
553
  const userId = this.keyData["user_id"];
517
554
  if (userId) {
@@ -530,6 +567,7 @@ export class InferenceEngine {
530
567
  }
531
568
  function renderErrorPanel(msg) {
532
569
  const text = msg.replace(/\[bold yellow\]|\[yellow\]|\[\/.*?\]/g, "");
533
- process.stdout.write(`\x1b[31m${text}\x1b[0m\n`);
570
+ renderPanel(text, "Error", "", true, "31");
571
+ process.stdout.write("\n");
534
572
  }
535
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") {
@@ -357,23 +449,84 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
357
449
  const w = terminalWidth();
358
450
  const borderW = Math.max(w - 4, 10);
359
451
  const [row, col] = cursorLineCol(buffer, cursor);
360
- // Build the inner content line(s) the whole prefix+text (or placeholder)
361
- // lives on a single row when the buffer is empty, matching the original.
362
- let content;
452
+ // Build the inner content: prefix, then the text with the `▏` block cursor
453
+ // always drawn at the cursor position (mirrors the original rich frame).
454
+ let display;
363
455
  if (!buffer) {
364
- content = `\x1b[1m${prefix}\x1b[0m \x1b[1m▏\x1b[0m\x1b[2m${PROMPT_PLACEHOLDER}\x1b[0m`;
456
+ display = `\x1b[1m▏\x1b[0m\x1b[2m${PROMPT_PLACEHOLDER}\x1b[0m`;
365
457
  }
366
458
  else {
367
459
  const segs = splitBlocks(buffer, pasteSpans);
368
- let display = "";
460
+ // Recompute each segment's buffer range [a, b) so we can locate the cursor.
461
+ const ranges = [];
462
+ {
463
+ let pos = 0;
464
+ const pts = new Set([0, buffer.length]);
465
+ for (const [s, e] of pasteSpans) {
466
+ pts.add(s);
467
+ pts.add(e);
468
+ }
469
+ const sorted = [...pts].sort((a, b) => a - b);
470
+ const isPaste = (a, b) => pasteSpans.some(([s, e]) => s <= a && b <= e);
471
+ const used = new Set();
472
+ for (const seg of segs) {
473
+ for (let i = 0; i < sorted.length - 1; i++) {
474
+ const a = sorted[i];
475
+ const b = sorted[i + 1];
476
+ if (a === b || used.has(a))
477
+ continue;
478
+ used.add(a);
479
+ ranges.push([a, b]);
480
+ break;
481
+ }
482
+ }
483
+ }
484
+ // Determine the target segment + inside offset (mirrors Python).
485
+ let target = -1;
486
+ let inside = 0;
487
+ if (cursor >= buffer.length) {
488
+ target = segs.length - 1;
489
+ inside = ranges.length ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0] : 0;
490
+ }
491
+ else {
492
+ for (let i = 0; i < ranges.length; i++) {
493
+ const [a, b] = ranges[i];
494
+ if (a <= cursor && cursor <= b) {
495
+ target = i;
496
+ inside = cursor - a;
497
+ break;
498
+ }
499
+ }
500
+ if (target === -1) {
501
+ target = segs.length - 1;
502
+ inside = ranges.length ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0] : 0;
503
+ }
504
+ }
505
+ display = "";
369
506
  for (let i = 0; i < segs.length; i++) {
370
507
  const [, kind, disp] = segs[i];
371
508
  if (i && !endsWithWs(segs[i - 1][2]))
372
509
  display += " ";
373
- display += kind === "collapsed" ? `\x1b[1m\x1b[36m${disp}\x1b[0m` : disp;
510
+ if (i === target) {
511
+ if (kind === "collapsed") {
512
+ display += `\x1b[1m\x1b[36m${disp}\x1b[0m`;
513
+ if (!endsWithWs(disp))
514
+ display += " ";
515
+ display += `\x1b[1m▏\x1b[0m`;
516
+ }
517
+ else {
518
+ const bold = i === target;
519
+ display += disp.slice(0, inside);
520
+ display += `\x1b[1m▏\x1b[0m`;
521
+ display += disp.slice(inside);
522
+ }
523
+ }
524
+ else {
525
+ display += kind === "collapsed" ? `\x1b[1m\x1b[36m${disp}\x1b[0m` : disp;
526
+ }
374
527
  }
375
- content = `\x1b[1m${prefix}\x1b[0m ${display}`;
376
528
  }
529
+ const content = `\x1b[1m${prefix}\x1b[0m ${display}`;
377
530
  const lines = content.split("\n");
378
531
  // Top border with the label embedded on the left.
379
532
  const topPad = Math.max(borderW - label.length - 2, 0);
@@ -387,10 +540,12 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
387
540
  }
388
541
  const bottom = `\x1b[90m╰${"─".repeat(borderW)}╯\x1b[0m`;
389
542
  const frame = [top, ...body, bottom].join("\n");
390
- // Cursor placement: one row below the top border. Column includes the
391
- // "│ " left border (2) plus "prefix " (prefix.length + 1).
543
+ // Cursor placement: one row below the top border. Preceding columns are
544
+ // `│ ` (2) + prefix (prefix.length) + ` ` (1) = prefix.length+3, so the first
545
+ // display char (the `▏` block cursor) sits at column prefix.length+4 relative
546
+ // to line column `col`.
392
547
  const cursorRow = row + 1;
393
- const cursorCol = col + prefix.length + 3;
548
+ const cursorCol = col + prefix.length + 4;
394
549
  const totalRows = body.length + 2;
395
550
  return { frame, cursorRow, cursorCol, totalRows };
396
551
  }
@@ -418,6 +573,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
418
573
  if (process.stdin.isTTY)
419
574
  process.stdin.setRawMode(true);
420
575
  process.stdin.resume();
576
+ hideCursor();
421
577
  let done = false;
422
578
  let lastCursorRow = 0;
423
579
  let lastTotalRows = 0;
@@ -426,19 +582,22 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
426
582
  return;
427
583
  done = true;
428
584
  process.stdin.setRawMode(false);
429
- // Erase the whole prompt box (which occupies `lastTotalRows` lines) so no
430
- // frame borders are left behind, then leave the cursor on the box's top
431
- // row (a blank line). That way the caller's single leading newline yields
432
- // 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.
433
591
  process.stdout.write(`\x1b[${lastCursorRow}A`);
434
592
  for (let i = 0; i < lastTotalRows; i++) {
435
593
  process.stdout.write("\r\x1b[K");
436
594
  if (i < lastTotalRows - 1)
437
595
  process.stdout.write("\n");
438
596
  }
439
- // Cursor is now on the last box row; move back up to the box's top row.
440
597
  if (lastTotalRows > 1)
441
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");
442
601
  resolve(resolveVal);
443
602
  };
444
603
  const repaint = (isFirst = false) => {
@@ -446,7 +605,11 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
446
605
  lastCursorRow = cursorRow;
447
606
  lastTotalRows = totalRows;
448
607
  const frameLines = frame.split("\n");
449
- 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 {
450
613
  // Cursor currently sits at cursorRow (inside the box). Move to the top
451
614
  // border, then clear+rewrite each line so old content is fully removed.
452
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.5",
3
+ "version": "1.0.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },