@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.
Files changed (3) hide show
  1. package/dist/cli.js +142 -20
  2. package/dist/ui.js +88 -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,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
- 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
+ // 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
- process.stdout.write("\n");
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
- pickConversation() {
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
- this.showConversationsPlain(recs);
184
- return null;
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
- renderPanel(`Invalid conversation number: ${num}`, "Error");
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
- renderPanel(`Conversation ${sid} not found.`, "Error");
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
- renderPanel("Usage: /effort none|low|high", "Error");
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
- export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
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
- process.stdout.write(`\x1b[${borderStyle}m${top}\x1b[0m\n`);
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
- process.stdout.write(`\x1b[${borderStyle}m│\x1b[0m ${line}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m\n`);
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
- process.stdout.write(`\x1b[${borderStyle}m${bottom}\x1b[0m\n`);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },