@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.
Files changed (3) hide show
  1. package/dist/cli.js +138 -18
  2. package/dist/ui.js +85 -4
  3. 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
- process.stdout.write("\n");
48
- for (const [cmd, desc] of COMMAND_HELP) {
49
- // The whole command column is bold; any `<arg>` placeholder (e.g. <n>,
50
- // <level>) is additionally cyan — matching the original's
51
- // `[bold cyan]<n>[/bold cyan]`. Pad based on the plain text length so the
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
- const pad = Math.max(18 - cmd.length, 0);
55
- process.stdout.write(`\x1b[1m${styled}${" ".repeat(pad)}\x1b[0m\x1b[2m${desc}\x1b[0m\n`);
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
- pickConversation() {
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
- this.showConversationsPlain(recs);
184
- return null;
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
- renderPanel(`Invalid conversation number: ${num}`, "Error");
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
- renderPanel(`Conversation ${sid} not found.`, "Error");
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
- renderPanel("Usage: /effort none|low|high", "Error");
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
- export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
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
- process.stdout.write(`\x1b[${borderStyle}m${top}\x1b[0m\n`);
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
- process.stdout.write(`\x1b[${borderStyle}m│\x1b[0m ${line}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m\n`);
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
- process.stdout.write(`\x1b[${borderStyle}m${bottom}\x1b[0m\n`);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },