@oxecli/oxe 1.0.6 → 1.0.8
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 +33 -3
- package/dist/config.js +4 -1
- package/dist/engine.js +86 -49
- package/dist/ui.js +137 -20
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ export class CLI {
|
|
|
15
15
|
story = [];
|
|
16
16
|
sessionId = null;
|
|
17
17
|
promptHistory = [];
|
|
18
|
+
interruptPending = false;
|
|
18
19
|
constructor() {
|
|
19
20
|
clearScreen();
|
|
20
21
|
renderPanel("AI CODING AGENT", "", "Engine: Ready", false);
|
|
@@ -107,7 +108,8 @@ export class CLI {
|
|
|
107
108
|
if (footer) {
|
|
108
109
|
if (!footer.startsWith("("))
|
|
109
110
|
footer = `(${footer})`;
|
|
110
|
-
|
|
111
|
+
// Footer: labels white, only the backtick values grey (not the whole line).
|
|
112
|
+
process.stdout.write("\n" + aiMarkdown(footer) + "\n");
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
else {
|
|
@@ -138,6 +140,10 @@ export class CLI {
|
|
|
138
140
|
else if (typ === "tool") {
|
|
139
141
|
this.printToolEntry(e["started"], text, e["status"] ?? "");
|
|
140
142
|
}
|
|
143
|
+
else if (typ === "footer") {
|
|
144
|
+
// Footer: labels white, only the backtick values grey (not the whole line).
|
|
145
|
+
process.stdout.write(aiMarkdown(text) + "\n");
|
|
146
|
+
}
|
|
141
147
|
else {
|
|
142
148
|
process.stdout.write(mutedMarkdown(text) + "\n");
|
|
143
149
|
}
|
|
@@ -210,6 +216,7 @@ export class CLI {
|
|
|
210
216
|
const [input, spans] = await askBottomPrompt("You", "❯", this.promptHistory);
|
|
211
217
|
if (!input.trim())
|
|
212
218
|
continue;
|
|
219
|
+
this.interruptPending = false;
|
|
213
220
|
this.promptHistory.push(input);
|
|
214
221
|
const lower = input.trim().toLowerCase();
|
|
215
222
|
if (lower === "/exit" || lower === "/quit") {
|
|
@@ -260,12 +267,35 @@ export class CLI {
|
|
|
260
267
|
this.saveSession(engine);
|
|
261
268
|
}
|
|
262
269
|
catch (err) {
|
|
263
|
-
if (err?.message === "
|
|
270
|
+
if (err?.message === "eof") {
|
|
264
271
|
this.saveSession(engine);
|
|
265
272
|
await engine.cleanupStoredResponses();
|
|
266
|
-
process.stdout.write("\
|
|
273
|
+
process.stdout.write("\nSession closing via exit interrupt hook.\n");
|
|
267
274
|
break;
|
|
268
275
|
}
|
|
276
|
+
if (err?.message === "interrupt") {
|
|
277
|
+
const wasActive = engine.inQuery;
|
|
278
|
+
engine.inQuery = false;
|
|
279
|
+
// Drop the pending user message/story entry that was never run.
|
|
280
|
+
if (this.inputItems.length && this.inputItems[this.inputItems.length - 1]["role"] === "user") {
|
|
281
|
+
this.inputItems.pop();
|
|
282
|
+
}
|
|
283
|
+
if (this.story.length && this.story[this.story.length - 1]["type"] === "user") {
|
|
284
|
+
this.story.pop();
|
|
285
|
+
}
|
|
286
|
+
if (this.interruptPending) {
|
|
287
|
+
this.saveSession(engine);
|
|
288
|
+
await engine.cleanupStoredResponses();
|
|
289
|
+
process.stdout.write("\nClosing terminal session. Goodbye!\n");
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
this.interruptPending = true;
|
|
293
|
+
process.stdout.write("\n");
|
|
294
|
+
process.stdout.write(wasActive
|
|
295
|
+
? "\x1b[2mInterrupted. Press Ctrl+C again to exit, or type a new prompt.\x1b[0m\n"
|
|
296
|
+
: "\x1b[2mNo active response. Press Ctrl+C again to exit.\x1b[0m\n");
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
269
299
|
throw err;
|
|
270
300
|
}
|
|
271
301
|
}
|
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
|
-
|
|
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
|
-
|
|
147
|
-
const workStatus = new
|
|
148
|
-
|
|
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(
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
@@ -364,7 +400,7 @@ export class InferenceEngine {
|
|
|
364
400
|
const stats = { thinking: 0, tokens: 0, reasoning: 0, input: 0 };
|
|
365
401
|
const queryStart = Date.now();
|
|
366
402
|
const footerText = () => `(Thought for ${tickDuration(stats["thinking"])} · Worked for ${tickDuration((Date.now() - queryStart) / 1000)} · Used \`${stats["tokens"].toLocaleString()}\` tokens)`;
|
|
367
|
-
const summary = () =>
|
|
403
|
+
const summary = () => aiMarkdown(footerText());
|
|
368
404
|
const attachFooter = (items) => {
|
|
369
405
|
const footer = footerText();
|
|
370
406
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
@@ -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(
|
|
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
|
|
500
|
-
|
|
501
|
-
"[
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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,103 @@ 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
|
+
// Cursor is always at the home position (col 0) after each draw. Clear
|
|
310
|
+
// `clearRows` lines downward from home, rewrite the content, then return
|
|
311
|
+
// the cursor to home (col 0) so the next frame overwrites in place.
|
|
312
|
+
for (let i = 0; i < clearRows; i++) {
|
|
313
|
+
process.stdout.write("\r\x1b[2K");
|
|
314
|
+
if (i < clearRows - 1)
|
|
315
|
+
process.stdout.write("\n");
|
|
316
|
+
}
|
|
317
|
+
// Cursor is now `clearRows-1` lines below home; move back to home.
|
|
318
|
+
if (clearRows > 1)
|
|
319
|
+
process.stdout.write(`\x1b[${clearRows - 1}A\r`);
|
|
320
|
+
else
|
|
321
|
+
process.stdout.write("\r");
|
|
322
|
+
process.stdout.write(rendered);
|
|
323
|
+
// Return cursor to home (col 0) for the next draw.
|
|
324
|
+
if (newRows > 1)
|
|
325
|
+
process.stdout.write(`\x1b[${newRows - 1}A\r`);
|
|
326
|
+
else
|
|
327
|
+
process.stdout.write("\r");
|
|
328
|
+
this.rows = newRows;
|
|
329
|
+
}
|
|
330
|
+
stop() {
|
|
331
|
+
if (this.timer) {
|
|
332
|
+
clearInterval(this.timer);
|
|
333
|
+
this.timer = null;
|
|
334
|
+
}
|
|
335
|
+
if (this.enabled && this.rows > 0) {
|
|
336
|
+
// Cursor is at home (col 0); clear the rendered rows downward.
|
|
337
|
+
for (let i = 0; i < this.rows; i++) {
|
|
338
|
+
process.stdout.write("\r\x1b[2K");
|
|
339
|
+
if (i < this.rows - 1)
|
|
340
|
+
process.stdout.write("\n");
|
|
341
|
+
}
|
|
342
|
+
if (this.rows > 1)
|
|
343
|
+
process.stdout.write(`\x1b[${this.rows - 1}A\r`);
|
|
344
|
+
else
|
|
345
|
+
process.stdout.write("\r");
|
|
346
|
+
}
|
|
347
|
+
this.rows = 0;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
248
351
|
// Tool action formatter
|
|
249
352
|
// ---------------------------------------------------------------------------
|
|
250
353
|
export function formatToolAction(name, argumentsJson, status = "ok") {
|
|
@@ -481,35 +584,51 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
481
584
|
if (process.stdin.isTTY)
|
|
482
585
|
process.stdin.setRawMode(true);
|
|
483
586
|
process.stdin.resume();
|
|
587
|
+
hideCursor();
|
|
484
588
|
let done = false;
|
|
485
589
|
let lastCursorRow = 0;
|
|
486
590
|
let lastTotalRows = 0;
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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.
|
|
591
|
+
// Erase the prompt region: a leading blank line + the box. Cursor sits at
|
|
592
|
+
// lastCursorRow inside the box; move up to the box's top border, erase the
|
|
593
|
+
// box rows, then erase the leading blank line, leaving the cursor on that
|
|
594
|
+
// blank row. The caller's single leading newline then yields exactly ONE
|
|
595
|
+
// blank row before the echoed user prompt / message.
|
|
596
|
+
const clearBox = () => {
|
|
496
597
|
process.stdout.write(`\x1b[${lastCursorRow}A`);
|
|
497
598
|
for (let i = 0; i < lastTotalRows; i++) {
|
|
498
599
|
process.stdout.write("\r\x1b[K");
|
|
499
600
|
if (i < lastTotalRows - 1)
|
|
500
601
|
process.stdout.write("\n");
|
|
501
602
|
}
|
|
502
|
-
// Cursor is now on the last box row; move back up to the box's top row.
|
|
503
603
|
if (lastTotalRows > 1)
|
|
504
604
|
process.stdout.write(`\x1b[${lastTotalRows - 1}A`);
|
|
505
|
-
|
|
605
|
+
process.stdout.write("\x1b[1A\r\x1b[K");
|
|
606
|
+
};
|
|
607
|
+
const settle = (value, isErr) => {
|
|
608
|
+
if (done)
|
|
609
|
+
return;
|
|
610
|
+
done = true;
|
|
611
|
+
process.stdin.setRawMode(false);
|
|
612
|
+
showCursor();
|
|
613
|
+
clearBox();
|
|
614
|
+
if (isErr)
|
|
615
|
+
reject(value);
|
|
616
|
+
else
|
|
617
|
+
resolve(value);
|
|
506
618
|
};
|
|
507
619
|
const repaint = (isFirst = false) => {
|
|
508
620
|
const { frame, cursorRow, cursorCol, totalRows } = renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor);
|
|
509
621
|
lastCursorRow = cursorRow;
|
|
510
622
|
lastTotalRows = totalRows;
|
|
511
623
|
const frameLines = frame.split("\n");
|
|
512
|
-
if (
|
|
624
|
+
if (isFirst) {
|
|
625
|
+
// Draw a leading blank line above the box (mirrors the original's
|
|
626
|
+
// Group(Text(""), panel)): move down one line, clear it as a blank, then
|
|
627
|
+
// move down again so the box starts below that blank.
|
|
628
|
+
process.stdout.write("\n");
|
|
629
|
+
process.stdout.write("\r\x1b[K\n");
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
513
632
|
// Cursor currently sits at cursorRow (inside the box). Move to the top
|
|
514
633
|
// border, then clear+rewrite each line so old content is fully removed.
|
|
515
634
|
process.stdout.write(`\x1b[${cursorRow}A`);
|
|
@@ -586,18 +705,16 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
586
705
|
};
|
|
587
706
|
const onKeypress = (str, key) => {
|
|
588
707
|
if (key && key.ctrl && key.name === "c") {
|
|
589
|
-
|
|
590
|
-
reject(new Error("interrupt"));
|
|
708
|
+
settle(new Error("interrupt"), true);
|
|
591
709
|
return;
|
|
592
710
|
}
|
|
593
711
|
if (key && key.ctrl && (key.name === "d" || key.name === "z")) {
|
|
594
|
-
|
|
595
|
-
reject(new Error("eof"));
|
|
712
|
+
settle(new Error("eof"), true);
|
|
596
713
|
return;
|
|
597
714
|
}
|
|
598
715
|
if (key && key.name === "return") {
|
|
599
716
|
if (buffer.trim()) {
|
|
600
|
-
|
|
717
|
+
settle([buffer, pasteSpans], false);
|
|
601
718
|
}
|
|
602
719
|
return;
|
|
603
720
|
}
|