@oxecli/oxe 1.0.16 → 1.0.17
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 +138 -18
- package/dist/ui.js +85 -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,19 +43,49 @@ 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
|
+
process.stdout.write("\n" + panel + "\n");
|
|
63
|
+
if (process.stdin.isTTY) {
|
|
64
|
+
// Wait for a close key, then erase the panel (mirrors Python's Live).
|
|
65
|
+
enableRawStdin();
|
|
66
|
+
try {
|
|
67
|
+
for (;;) {
|
|
68
|
+
if (await this.awaitRawCloseKey())
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
disableRawStdin();
|
|
74
|
+
}
|
|
75
|
+
const rowsShown = panel.split("\n").length;
|
|
76
|
+
process.stdout.write(`\x1b[${rowsShown}A\r\x1b[J`);
|
|
56
77
|
}
|
|
57
78
|
process.stdout.write("\n");
|
|
58
79
|
}
|
|
80
|
+
async awaitRawCloseKey() {
|
|
81
|
+
const k = await waitRawKey();
|
|
82
|
+
const closeNames = ["escape", "return", "enter", "up", "down", "left", "right"];
|
|
83
|
+
if (k.ctrl && (k.name === "c" || k.name === "z"))
|
|
84
|
+
return true;
|
|
85
|
+
if (k.str === "q" || k.str === "Q" || k.str === "\x1b" || closeNames.includes(k.name))
|
|
86
|
+
return true;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
59
89
|
renderHistory(items, maxItems = max_resume_history_items) {
|
|
60
90
|
const outputsByCallId = new Map();
|
|
61
91
|
for (const it of items) {
|
|
@@ -173,25 +203,110 @@ export class CLI {
|
|
|
173
203
|
}
|
|
174
204
|
process.stdout.write("\n");
|
|
175
205
|
}
|
|
176
|
-
|
|
206
|
+
/** Build the interactive picker panel (mirrors Python `_render_picker`). */
|
|
207
|
+
renderPickerPanel(recs, selected, visible = 8) {
|
|
208
|
+
const total = recs.length;
|
|
209
|
+
const half = Math.floor(visible / 2);
|
|
210
|
+
const lo = Math.min(Math.max(selected - half, 0), Math.max(total - visible, 0));
|
|
211
|
+
const hi = Math.min(lo + visible, total);
|
|
212
|
+
const rows = [];
|
|
213
|
+
for (let idx = lo; idx < hi; idx++) {
|
|
214
|
+
const rec = recs[idx];
|
|
215
|
+
const sid = String(rec["id"] ?? "?");
|
|
216
|
+
const updated = String(rec["updated_at"] ?? "").slice(0, 16).replace("T", " ");
|
|
217
|
+
const label = truncateLabel(rec["label"]);
|
|
218
|
+
const items = rec["input_items"] ?? [];
|
|
219
|
+
const toks = estimateTokens(items).toLocaleString();
|
|
220
|
+
const content = `\x1b[1m${sid.padStart(4)}\x1b[0m` +
|
|
221
|
+
`\x1b[2m ${updated}\x1b[0m ${label}` +
|
|
222
|
+
`\x1b[2m ${items.length} items · ${toks} tok\x1b[0m`;
|
|
223
|
+
rows.push({
|
|
224
|
+
cells: [idx === selected ? "\x1b[1m\x1b[36m❯\x1b[0m" : "", content],
|
|
225
|
+
style: idx === selected ? "\x1b[1m" : undefined,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const below = total - hi;
|
|
229
|
+
let footer = "";
|
|
230
|
+
if (lo > 0 && below > 0)
|
|
231
|
+
footer = `\x1b[2m▲ ${lo} earlier · ▼ ${below} more\x1b[0m`;
|
|
232
|
+
else if (lo > 0)
|
|
233
|
+
footer = `\x1b[2m▲ ${lo} earlier\x1b[0m`;
|
|
234
|
+
else if (below > 0)
|
|
235
|
+
footer = `\x1b[2m▼ ${below} more\x1b[0m`;
|
|
236
|
+
if (footer)
|
|
237
|
+
rows.push({ cells: ["", footer] });
|
|
238
|
+
const title = `Conversations \x1b[1m\x1b[36m${lo + 1}-${hi}/${total}\x1b[0m (↑/↓ · Quit (q) · Enter)`;
|
|
239
|
+
return renderTableString(rows, {
|
|
240
|
+
title,
|
|
241
|
+
borderStyle: "90",
|
|
242
|
+
expand: true,
|
|
243
|
+
titleAlign: "left",
|
|
244
|
+
colGap: 0,
|
|
245
|
+
colWidths: [3, undefined],
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/** Interactive arrow up/down project picker (mirrors Python `_pick_conversation`). */
|
|
249
|
+
async pickConversation() {
|
|
177
250
|
const recs = listSessions();
|
|
178
251
|
if (!recs.length) {
|
|
179
252
|
process.stdout.write("\n");
|
|
180
253
|
renderPanel("No saved conversations yet. Type a prompt to start one.", "Conversations");
|
|
181
254
|
return null;
|
|
182
255
|
}
|
|
183
|
-
|
|
184
|
-
|
|
256
|
+
if (!process.stdin.isTTY) {
|
|
257
|
+
this.showConversationsPlain(recs);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
enableRawStdin();
|
|
261
|
+
let selected = 0;
|
|
262
|
+
let lastRows = 0;
|
|
263
|
+
const draw = () => {
|
|
264
|
+
const frame = "\n" + this.renderPickerPanel(recs, selected);
|
|
265
|
+
const n = frame.split("\n").length;
|
|
266
|
+
if (lastRows > 0)
|
|
267
|
+
process.stdout.write(`\x1b[${lastRows - 1}A\r\x1b[J`);
|
|
268
|
+
process.stdout.write(frame);
|
|
269
|
+
lastRows = n;
|
|
270
|
+
};
|
|
271
|
+
draw();
|
|
272
|
+
try {
|
|
273
|
+
for (;;) {
|
|
274
|
+
const k = await waitRawKey();
|
|
275
|
+
if (k.name === "up") {
|
|
276
|
+
selected = (selected - 1 + recs.length) % recs.length;
|
|
277
|
+
draw();
|
|
278
|
+
}
|
|
279
|
+
else if (k.name === "down") {
|
|
280
|
+
selected = (selected + 1) % recs.length;
|
|
281
|
+
draw();
|
|
282
|
+
}
|
|
283
|
+
else if (k.name === "return" || k.name === "enter") {
|
|
284
|
+
return Number(recs[selected]["id"] ?? null);
|
|
285
|
+
}
|
|
286
|
+
else if (k.str === "q" ||
|
|
287
|
+
k.str === "Q" ||
|
|
288
|
+
k.name === "escape" ||
|
|
289
|
+
(k.ctrl && (k.name === "c" || k.name === "z"))) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
process.stdout.write(`\x1b[${lastRows - 1}A\r\x1b[J`);
|
|
296
|
+
disableRawStdin();
|
|
297
|
+
}
|
|
185
298
|
}
|
|
186
299
|
async resumeConversation(engine, num) {
|
|
187
300
|
const sid = parseInt(num, 10);
|
|
188
301
|
if (Number.isNaN(sid)) {
|
|
189
|
-
|
|
302
|
+
process.stdout.write("\n");
|
|
303
|
+
renderPanel(`Invalid conversation number: ${num}`, "Error", "", true, "31");
|
|
190
304
|
return;
|
|
191
305
|
}
|
|
192
306
|
const rec = loadSession(sid);
|
|
193
307
|
if (!rec) {
|
|
194
|
-
|
|
308
|
+
process.stdout.write("\n");
|
|
309
|
+
renderPanel(`Conversation ${sid} not found.`, "Error", "", true, "31");
|
|
195
310
|
return;
|
|
196
311
|
}
|
|
197
312
|
this.saveSession(engine);
|
|
@@ -232,7 +347,7 @@ export class CLI {
|
|
|
232
347
|
break;
|
|
233
348
|
}
|
|
234
349
|
if (lower === "/help") {
|
|
235
|
-
this.showHelp();
|
|
350
|
+
await this.showHelp();
|
|
236
351
|
continue;
|
|
237
352
|
}
|
|
238
353
|
if (lower === "/clear") {
|
|
@@ -249,7 +364,10 @@ export class CLI {
|
|
|
249
364
|
if (lower.startsWith("/resume")) {
|
|
250
365
|
const parts = input.trim().split(/\s+/);
|
|
251
366
|
if (parts.length === 1) {
|
|
252
|
-
this.pickConversation();
|
|
367
|
+
const picked = await this.pickConversation();
|
|
368
|
+
if (picked != null && !Number.isNaN(picked)) {
|
|
369
|
+
await this.resumeConversation(engine, String(picked));
|
|
370
|
+
}
|
|
253
371
|
}
|
|
254
372
|
else {
|
|
255
373
|
await this.resumeConversation(engine, parts[1]);
|
|
@@ -264,7 +382,9 @@ export class CLI {
|
|
|
264
382
|
process.stdout.write(`\n\x1b[2mReasoning effort set to ${effort}.\x1b[0m\n`);
|
|
265
383
|
}
|
|
266
384
|
else {
|
|
267
|
-
|
|
385
|
+
process.stdout.write("\n");
|
|
386
|
+
renderPanel("Usage: /effort none|low|high", "Error", "", true, "31");
|
|
387
|
+
process.stdout.write("\n");
|
|
268
388
|
}
|
|
269
389
|
continue;
|
|
270
390
|
}
|
package/dist/ui.js
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
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
|
+
process.stdin.pause();
|
|
28
|
+
showCursor();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Wait for a single raw keypress. */
|
|
32
|
+
export function waitRawKey() {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const onKeypress = (str, key) => {
|
|
35
|
+
process.stdin.removeListener("keypress", onKeypress);
|
|
36
|
+
resolve({ name: key?.name ?? "", str: str ?? "", ctrl: !!(key?.ctrl) });
|
|
37
|
+
};
|
|
38
|
+
process.stdin.on("keypress", onKeypress);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
3
42
|
// Terminal helpers
|
|
4
43
|
// ---------------------------------------------------------------------------
|
|
5
44
|
export function isWindows() {
|
|
@@ -142,7 +181,7 @@ export function mutedMarkdown(text) {
|
|
|
142
181
|
* - title/subtitle are embedded in the top/bottom borders, centered by
|
|
143
182
|
* default (rich default) or left-aligned.
|
|
144
183
|
*/
|
|
145
|
-
|
|
184
|
+
function panelString(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
|
|
146
185
|
titleAlign = "center") {
|
|
147
186
|
const styled = markupToAnsi(content);
|
|
148
187
|
const styledLines = styled.split("\n");
|
|
@@ -172,17 +211,59 @@ titleAlign = "center") {
|
|
|
172
211
|
const right = fill - left;
|
|
173
212
|
return `${"─".repeat(left)}${inner}${"─".repeat(right)}`;
|
|
174
213
|
};
|
|
214
|
+
const out = [];
|
|
175
215
|
const top = `╭${embed(title, titleAlign)}╮`;
|
|
176
|
-
|
|
216
|
+
out.push(`\x1b[${borderStyle}m${top}\x1b[0m`);
|
|
177
217
|
for (let i = 0; i < styledLines.length; i++) {
|
|
178
218
|
const line = styledLines[i];
|
|
179
219
|
const plain = plainLines[i] ?? "";
|
|
180
220
|
// Keep exactly one space padding each side; only pad the right to fill.
|
|
181
221
|
const pad = Math.max(innerW - plain.length - 2, 0);
|
|
182
|
-
|
|
222
|
+
out.push(`\x1b[${borderStyle}m│\x1b[0m ${line}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m`);
|
|
183
223
|
}
|
|
184
224
|
const bottom = `╰${embed(subtitle, "center")}╯`;
|
|
185
|
-
|
|
225
|
+
out.push(`\x1b[${borderStyle}m${bottom}\x1b[0m`);
|
|
226
|
+
return out.join("\n");
|
|
227
|
+
}
|
|
228
|
+
export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
|
|
229
|
+
titleAlign = "center") {
|
|
230
|
+
process.stdout.write(panelString(content, title, subtitle, expand, borderStyle, titleAlign) + "\n");
|
|
231
|
+
}
|
|
232
|
+
function plainLen(s) {
|
|
233
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Render a rich-style bordered table panel (mirrors rich's Table + Panel).
|
|
237
|
+
*
|
|
238
|
+
* `rows` is a list of {cells, style?} where `style` (ANSI prefix) is applied to
|
|
239
|
+
* the whole row. Columns are left-aligned and padded; `colWidths` can fix a
|
|
240
|
+
* column's width (used for the selection gutter).
|
|
241
|
+
*/
|
|
242
|
+
export function renderTableString(rows, opts = {}) {
|
|
243
|
+
const ncols = Math.max(0, ...rows.map((r) => r.cells.length));
|
|
244
|
+
if (ncols === 0)
|
|
245
|
+
return panelString("", opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
|
|
246
|
+
const colGap = opts.colGap ?? 2;
|
|
247
|
+
const widths = [];
|
|
248
|
+
for (let c = 0; c < ncols; c++) {
|
|
249
|
+
let mw = 0;
|
|
250
|
+
for (const r of rows)
|
|
251
|
+
if (r.cells[c])
|
|
252
|
+
mw = Math.max(mw, plainLen(r.cells[c]));
|
|
253
|
+
widths.push(Math.max(mw, opts.colWidths?.[c] ?? 0));
|
|
254
|
+
}
|
|
255
|
+
const contentLines = [];
|
|
256
|
+
for (const r of rows) {
|
|
257
|
+
const parts = [];
|
|
258
|
+
for (let c = 0; c < ncols; c++) {
|
|
259
|
+
const cell = r.cells[c] ?? "";
|
|
260
|
+
const pad = c < ncols - 1 ? widths[c] - plainLen(cell) + colGap : 0;
|
|
261
|
+
parts.push(cell + " ".repeat(Math.max(pad, 0)));
|
|
262
|
+
}
|
|
263
|
+
const line = parts.join("");
|
|
264
|
+
contentLines.push(r.style ? `${r.style}${line}\x1b[0m` : line);
|
|
265
|
+
}
|
|
266
|
+
return panelString(contentLines.join("\n"), opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
|
|
186
267
|
}
|
|
187
268
|
// ---------------------------------------------------------------------------
|
|
188
269
|
// Durations
|