@oxecli/oxe 1.0.16 → 1.0.18
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 +142 -20
- package/dist/ui.js +88 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { loadOrPrompt, default_reasoning_effort, max_action_chars, max_resume_history_items, } from "./config.js";
|
|
4
4
|
import { InferenceEngine, estimateTokens } from "./engine.js";
|
|
5
5
|
import { saveSession, loadSession, listSessions, nextSessionId, conversationLabel, COMMAND_HELP, toolOutputFailed, } from "./sessions.js";
|
|
6
|
-
import { clearScreen, enableAnsi, askBottomPrompt, userDisplayText, aiMarkdown, mutedMarkdown, messageText, formatToolAction, truncateEllipsis, renderPanel, } from "./ui.js";
|
|
6
|
+
import { clearScreen, enableAnsi, askBottomPrompt, userDisplayText, aiMarkdown, mutedMarkdown, messageText, formatToolAction, truncateEllipsis, renderPanel, renderTableString, enableRawStdin, disableRawStdin, waitRawKey, } from "./ui.js";
|
|
7
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
function truncateLabel(s) {
|
|
9
9
|
const t = String(s || "(conversation)").trim();
|
|
@@ -43,18 +43,52 @@ export class CLI {
|
|
|
43
43
|
const style = status === "failed" ? "\x1b[31m" : "\x1b[32m";
|
|
44
44
|
process.stdout.write(`${style}${truncateEllipsis(String(text), max_action_chars, "text")}\x1b[0m\n`);
|
|
45
45
|
}
|
|
46
|
-
showHelp() {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// ANSI codes don't skew the column alignment.
|
|
46
|
+
async showHelp() {
|
|
47
|
+
// Mirror Python's `_show_help`: a bordered table panel with the command
|
|
48
|
+
// column bold (with `<arg>` placeholders cyan) and descriptions dim, inside
|
|
49
|
+
// a panel titled "Commands · Enter / Ctrl+C closes". In a TTY it stays on
|
|
50
|
+
// screen until Enter/Ctrl+C/arrows/q closes it.
|
|
51
|
+
const rows = COMMAND_HELP.map(([cmd, desc]) => {
|
|
53
52
|
const styled = cmd.replace(/(<[^>]+>)/g, "\x1b[36m$1\x1b[0m");
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
return { cells: [`\x1b[1m${styled}\x1b[0m`, `\x1b[2m${desc}\x1b[0m`] };
|
|
54
|
+
});
|
|
55
|
+
const panel = renderTableString(rows, {
|
|
56
|
+
title: "Commands · Enter / Ctrl+C closes",
|
|
57
|
+
borderStyle: "90",
|
|
58
|
+
expand: false,
|
|
59
|
+
titleAlign: "left",
|
|
60
|
+
colGap: 2,
|
|
61
|
+
});
|
|
62
|
+
// Write the panel starting on a fresh line. No extra leading/trailing blank
|
|
63
|
+
// here — askBottomPrompt's own leading newline supplies the single gap.
|
|
64
|
+
process.stdout.write(panel + "\n");
|
|
65
|
+
if (process.stdin.isTTY) {
|
|
66
|
+
// Wait for a close key, then erase the panel (mirrors Python's Live).
|
|
67
|
+
enableRawStdin();
|
|
68
|
+
try {
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (await this.awaitRawCloseKey())
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
disableRawStdin();
|
|
76
|
+
}
|
|
77
|
+
// Cursor sits one line below the panel; move up to its top border and
|
|
78
|
+
// clear downward so the whole panel disappears and the cursor lands back
|
|
79
|
+
// on the fresh line before the prompt.
|
|
80
|
+
const n = panel.split("\n").length;
|
|
81
|
+
process.stdout.write(`\x1b[${n}A\r\x1b[J`);
|
|
56
82
|
}
|
|
57
|
-
|
|
83
|
+
}
|
|
84
|
+
async awaitRawCloseKey() {
|
|
85
|
+
const k = await waitRawKey();
|
|
86
|
+
const closeNames = ["escape", "return", "enter", "up", "down", "left", "right"];
|
|
87
|
+
if (k.ctrl && (k.name === "c" || k.name === "z"))
|
|
88
|
+
return true;
|
|
89
|
+
if (k.str === "q" || k.str === "Q" || k.str === "\x1b" || closeNames.includes(k.name))
|
|
90
|
+
return true;
|
|
91
|
+
return false;
|
|
58
92
|
}
|
|
59
93
|
renderHistory(items, maxItems = max_resume_history_items) {
|
|
60
94
|
const outputsByCallId = new Map();
|
|
@@ -171,27 +205,111 @@ export class CLI {
|
|
|
171
205
|
for (const rec of recs) {
|
|
172
206
|
process.stdout.write(this.conversationRow(rec) + "\n");
|
|
173
207
|
}
|
|
174
|
-
process.stdout.write("\n");
|
|
175
208
|
}
|
|
176
|
-
|
|
209
|
+
/** Build the interactive picker panel (mirrors Python `_render_picker`). */
|
|
210
|
+
renderPickerPanel(recs, selected, visible = 8) {
|
|
211
|
+
const total = recs.length;
|
|
212
|
+
const half = Math.floor(visible / 2);
|
|
213
|
+
const lo = Math.min(Math.max(selected - half, 0), Math.max(total - visible, 0));
|
|
214
|
+
const hi = Math.min(lo + visible, total);
|
|
215
|
+
const rows = [];
|
|
216
|
+
for (let idx = lo; idx < hi; idx++) {
|
|
217
|
+
const rec = recs[idx];
|
|
218
|
+
const sid = String(rec["id"] ?? "?");
|
|
219
|
+
const updated = String(rec["updated_at"] ?? "").slice(0, 16).replace("T", " ");
|
|
220
|
+
const label = truncateLabel(rec["label"]);
|
|
221
|
+
const items = rec["input_items"] ?? [];
|
|
222
|
+
const toks = estimateTokens(items).toLocaleString();
|
|
223
|
+
const content = `\x1b[1m${sid.padStart(4)}\x1b[0m` +
|
|
224
|
+
`\x1b[2m ${updated}\x1b[0m ${label}` +
|
|
225
|
+
`\x1b[2m ${items.length} items · ${toks} tok\x1b[0m`;
|
|
226
|
+
rows.push({
|
|
227
|
+
cells: [idx === selected ? "\x1b[1m\x1b[36m❯\x1b[0m" : "", content],
|
|
228
|
+
style: idx === selected ? "\x1b[1m" : undefined,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const below = total - hi;
|
|
232
|
+
let footer = "";
|
|
233
|
+
if (lo > 0 && below > 0)
|
|
234
|
+
footer = `\x1b[2m▲ ${lo} earlier · ▼ ${below} more\x1b[0m`;
|
|
235
|
+
else if (lo > 0)
|
|
236
|
+
footer = `\x1b[2m▲ ${lo} earlier\x1b[0m`;
|
|
237
|
+
else if (below > 0)
|
|
238
|
+
footer = `\x1b[2m▼ ${below} more\x1b[0m`;
|
|
239
|
+
if (footer)
|
|
240
|
+
rows.push({ cells: ["", footer] });
|
|
241
|
+
const title = `Conversations \x1b[1m\x1b[36m${lo + 1}-${hi}/${total}\x1b[0m (↑/↓ · Quit (q) · Enter)`;
|
|
242
|
+
return renderTableString(rows, {
|
|
243
|
+
title,
|
|
244
|
+
borderStyle: "90",
|
|
245
|
+
expand: true,
|
|
246
|
+
titleAlign: "left",
|
|
247
|
+
colGap: 0,
|
|
248
|
+
colWidths: [3, undefined],
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/** Interactive arrow up/down project picker (mirrors Python `_pick_conversation`). */
|
|
252
|
+
async pickConversation() {
|
|
177
253
|
const recs = listSessions();
|
|
178
254
|
if (!recs.length) {
|
|
179
255
|
process.stdout.write("\n");
|
|
180
256
|
renderPanel("No saved conversations yet. Type a prompt to start one.", "Conversations");
|
|
181
257
|
return null;
|
|
182
258
|
}
|
|
183
|
-
|
|
184
|
-
|
|
259
|
+
if (!process.stdin.isTTY) {
|
|
260
|
+
this.showConversationsPlain(recs);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
enableRawStdin();
|
|
264
|
+
let selected = 0;
|
|
265
|
+
let lastRows = 0;
|
|
266
|
+
const draw = () => {
|
|
267
|
+
const frame = "\n" + this.renderPickerPanel(recs, selected);
|
|
268
|
+
const n = frame.split("\n").length;
|
|
269
|
+
if (lastRows > 0)
|
|
270
|
+
process.stdout.write(`\x1b[${lastRows - 1}A\r\x1b[J`);
|
|
271
|
+
process.stdout.write(frame);
|
|
272
|
+
lastRows = n;
|
|
273
|
+
};
|
|
274
|
+
draw();
|
|
275
|
+
try {
|
|
276
|
+
for (;;) {
|
|
277
|
+
const k = await waitRawKey();
|
|
278
|
+
if (k.name === "up") {
|
|
279
|
+
selected = (selected - 1 + recs.length) % recs.length;
|
|
280
|
+
draw();
|
|
281
|
+
}
|
|
282
|
+
else if (k.name === "down") {
|
|
283
|
+
selected = (selected + 1) % recs.length;
|
|
284
|
+
draw();
|
|
285
|
+
}
|
|
286
|
+
else if (k.name === "return" || k.name === "enter") {
|
|
287
|
+
return Number(recs[selected]["id"] ?? null);
|
|
288
|
+
}
|
|
289
|
+
else if (k.str === "q" ||
|
|
290
|
+
k.str === "Q" ||
|
|
291
|
+
k.name === "escape" ||
|
|
292
|
+
(k.ctrl && (k.name === "c" || k.name === "z"))) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
process.stdout.write(`\x1b[${lastRows - 1}A\r\x1b[J`);
|
|
299
|
+
disableRawStdin();
|
|
300
|
+
}
|
|
185
301
|
}
|
|
186
302
|
async resumeConversation(engine, num) {
|
|
187
303
|
const sid = parseInt(num, 10);
|
|
188
304
|
if (Number.isNaN(sid)) {
|
|
189
|
-
|
|
305
|
+
process.stdout.write("\n");
|
|
306
|
+
renderPanel(`Invalid conversation number: ${num}`, "Error", "", true, "31");
|
|
190
307
|
return;
|
|
191
308
|
}
|
|
192
309
|
const rec = loadSession(sid);
|
|
193
310
|
if (!rec) {
|
|
194
|
-
|
|
311
|
+
process.stdout.write("\n");
|
|
312
|
+
renderPanel(`Conversation ${sid} not found.`, "Error", "", true, "31");
|
|
195
313
|
return;
|
|
196
314
|
}
|
|
197
315
|
this.saveSession(engine);
|
|
@@ -232,7 +350,7 @@ export class CLI {
|
|
|
232
350
|
break;
|
|
233
351
|
}
|
|
234
352
|
if (lower === "/help") {
|
|
235
|
-
this.showHelp();
|
|
353
|
+
await this.showHelp();
|
|
236
354
|
continue;
|
|
237
355
|
}
|
|
238
356
|
if (lower === "/clear") {
|
|
@@ -249,7 +367,10 @@ export class CLI {
|
|
|
249
367
|
if (lower.startsWith("/resume")) {
|
|
250
368
|
const parts = input.trim().split(/\s+/);
|
|
251
369
|
if (parts.length === 1) {
|
|
252
|
-
this.pickConversation();
|
|
370
|
+
const picked = await this.pickConversation();
|
|
371
|
+
if (picked != null && !Number.isNaN(picked)) {
|
|
372
|
+
await this.resumeConversation(engine, String(picked));
|
|
373
|
+
}
|
|
253
374
|
}
|
|
254
375
|
else {
|
|
255
376
|
await this.resumeConversation(engine, parts[1]);
|
|
@@ -264,7 +385,8 @@ export class CLI {
|
|
|
264
385
|
process.stdout.write(`\n\x1b[2mReasoning effort set to ${effort}.\x1b[0m\n`);
|
|
265
386
|
}
|
|
266
387
|
else {
|
|
267
|
-
|
|
388
|
+
process.stdout.write("\n");
|
|
389
|
+
renderPanel("Usage: /effort none|low|high", "Error", "", true, "31");
|
|
268
390
|
}
|
|
269
391
|
continue;
|
|
270
392
|
}
|
package/dist/ui.js
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
import * as readline from "node:readline";
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
|
+
// Raw stdin key reading (for interactive help / resume picker)
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
let rawKeyListeners = 0;
|
|
6
|
+
/** Enter raw mode and emit keypress events on stdin. */
|
|
7
|
+
export function enableRawStdin() {
|
|
8
|
+
if (rawKeyListeners === 0) {
|
|
9
|
+
readline.emitKeypressEvents(process.stdin);
|
|
10
|
+
if (process.stdin.isTTY)
|
|
11
|
+
process.stdin.setRawMode(true);
|
|
12
|
+
process.stdin.resume();
|
|
13
|
+
hideCursor();
|
|
14
|
+
}
|
|
15
|
+
rawKeyListeners++;
|
|
16
|
+
}
|
|
17
|
+
/** Exit raw mode. */
|
|
18
|
+
export function disableRawStdin() {
|
|
19
|
+
rawKeyListeners = Math.max(0, rawKeyListeners - 1);
|
|
20
|
+
if (rawKeyListeners === 0) {
|
|
21
|
+
try {
|
|
22
|
+
process.stdin.setRawMode(false);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* ignore */
|
|
26
|
+
}
|
|
27
|
+
// Do NOT pause stdin here: askBottomPrompt re-acquires raw mode and
|
|
28
|
+
// resumes the stream itself. Pausing can race with that resume and drop
|
|
29
|
+
// keypress events on some terminals (prompt appearing "locked" after an
|
|
30
|
+
// interactive help/picker).
|
|
31
|
+
showCursor();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Wait for a single raw keypress. */
|
|
35
|
+
export function waitRawKey() {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const onKeypress = (str, key) => {
|
|
38
|
+
process.stdin.removeListener("keypress", onKeypress);
|
|
39
|
+
resolve({ name: key?.name ?? "", str: str ?? "", ctrl: !!(key?.ctrl) });
|
|
40
|
+
};
|
|
41
|
+
process.stdin.on("keypress", onKeypress);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
3
45
|
// Terminal helpers
|
|
4
46
|
// ---------------------------------------------------------------------------
|
|
5
47
|
export function isWindows() {
|
|
@@ -142,7 +184,7 @@ export function mutedMarkdown(text) {
|
|
|
142
184
|
* - title/subtitle are embedded in the top/bottom borders, centered by
|
|
143
185
|
* default (rich default) or left-aligned.
|
|
144
186
|
*/
|
|
145
|
-
|
|
187
|
+
function panelString(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
|
|
146
188
|
titleAlign = "center") {
|
|
147
189
|
const styled = markupToAnsi(content);
|
|
148
190
|
const styledLines = styled.split("\n");
|
|
@@ -172,17 +214,59 @@ titleAlign = "center") {
|
|
|
172
214
|
const right = fill - left;
|
|
173
215
|
return `${"─".repeat(left)}${inner}${"─".repeat(right)}`;
|
|
174
216
|
};
|
|
217
|
+
const out = [];
|
|
175
218
|
const top = `╭${embed(title, titleAlign)}╮`;
|
|
176
|
-
|
|
219
|
+
out.push(`\x1b[${borderStyle}m${top}\x1b[0m`);
|
|
177
220
|
for (let i = 0; i < styledLines.length; i++) {
|
|
178
221
|
const line = styledLines[i];
|
|
179
222
|
const plain = plainLines[i] ?? "";
|
|
180
223
|
// Keep exactly one space padding each side; only pad the right to fill.
|
|
181
224
|
const pad = Math.max(innerW - plain.length - 2, 0);
|
|
182
|
-
|
|
225
|
+
out.push(`\x1b[${borderStyle}m│\x1b[0m ${line}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m`);
|
|
183
226
|
}
|
|
184
227
|
const bottom = `╰${embed(subtitle, "center")}╯`;
|
|
185
|
-
|
|
228
|
+
out.push(`\x1b[${borderStyle}m${bottom}\x1b[0m`);
|
|
229
|
+
return out.join("\n");
|
|
230
|
+
}
|
|
231
|
+
export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
|
|
232
|
+
titleAlign = "center") {
|
|
233
|
+
process.stdout.write(panelString(content, title, subtitle, expand, borderStyle, titleAlign) + "\n");
|
|
234
|
+
}
|
|
235
|
+
function plainLen(s) {
|
|
236
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Render a rich-style bordered table panel (mirrors rich's Table + Panel).
|
|
240
|
+
*
|
|
241
|
+
* `rows` is a list of {cells, style?} where `style` (ANSI prefix) is applied to
|
|
242
|
+
* the whole row. Columns are left-aligned and padded; `colWidths` can fix a
|
|
243
|
+
* column's width (used for the selection gutter).
|
|
244
|
+
*/
|
|
245
|
+
export function renderTableString(rows, opts = {}) {
|
|
246
|
+
const ncols = Math.max(0, ...rows.map((r) => r.cells.length));
|
|
247
|
+
if (ncols === 0)
|
|
248
|
+
return panelString("", opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
|
|
249
|
+
const colGap = opts.colGap ?? 2;
|
|
250
|
+
const widths = [];
|
|
251
|
+
for (let c = 0; c < ncols; c++) {
|
|
252
|
+
let mw = 0;
|
|
253
|
+
for (const r of rows)
|
|
254
|
+
if (r.cells[c])
|
|
255
|
+
mw = Math.max(mw, plainLen(r.cells[c]));
|
|
256
|
+
widths.push(Math.max(mw, opts.colWidths?.[c] ?? 0));
|
|
257
|
+
}
|
|
258
|
+
const contentLines = [];
|
|
259
|
+
for (const r of rows) {
|
|
260
|
+
const parts = [];
|
|
261
|
+
for (let c = 0; c < ncols; c++) {
|
|
262
|
+
const cell = r.cells[c] ?? "";
|
|
263
|
+
const pad = c < ncols - 1 ? widths[c] - plainLen(cell) + colGap : 0;
|
|
264
|
+
parts.push(cell + " ".repeat(Math.max(pad, 0)));
|
|
265
|
+
}
|
|
266
|
+
const line = parts.join("");
|
|
267
|
+
contentLines.push(r.style ? `${r.style}${line}\x1b[0m` : line);
|
|
268
|
+
}
|
|
269
|
+
return panelString(contentLines.join("\n"), opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
|
|
186
270
|
}
|
|
187
271
|
// ---------------------------------------------------------------------------
|
|
188
272
|
// Durations
|