@hackerrank/astra-cli 0.1.0 → 0.1.1

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/src/repl.js CHANGED
@@ -15,7 +15,8 @@ import path from "node:path";
15
15
  import { execFileSync } from "node:child_process";
16
16
  import readline from "node:readline";
17
17
  import { fileURLToPath } from "node:url";
18
- import { ask } from "./config.js";
18
+ import { ask, saveAgentPrefs } from "./config.js";
19
+ import { AVAILABLE_MODELS, REASONING_LEVELS } from "./models.js";
19
20
 
20
21
  const C = {
21
22
  dim: (s) => `\x1b[2m${s}\x1b[0m`,
@@ -55,9 +56,9 @@ function pkgVersion() {
55
56
  // ASCII wordmark (figlet "standard" style) shown when a new chat starts.
56
57
  const BANNER = [
57
58
  " _ ",
58
- " __ _ ___ | |_ __ __ __ _ ",
59
- " / _` |/ __| | __| |__ __| / _` |",
60
- "| (_| |\\__ \\ | |_ | | | (_| |",
59
+ " __ _ ___ | |_ _ __ __ _ ",
60
+ " / _` |/ __| | __| | '__| / _` |",
61
+ "| (_| |\\__ \\ | |_ | | | (_| |",
61
62
  " \\__,_||___/ \\__| |_| \\__,_|",
62
63
  ];
63
64
 
@@ -131,13 +132,14 @@ function turnSummary({ turn, upTok, downTok, costUsd, costKind, seconds }) {
131
132
  * and cumulative token/context/cost usage. Returned as an array so the sticky
132
133
  * screen can pin them to the bottom rows.
133
134
  */
134
- function footerLines(agent, model, yolo) {
135
+ function footerLines(agent, model, yolo, mode = "agent") {
135
136
  const cols = process.stdout.columns || 100;
136
137
  const rule = "─".repeat(cols);
137
138
 
138
- const reasoning = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort;
139
+ const reasoning = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort || "off";
140
+ const modeTag = mode === "bench" ? C.yellow("◆ bench") : C.cyan("◆ agent");
139
141
  const modelBits = [`model: ${model.model}`];
140
- if (reasoning) modelBits.push(`reasoning: ${reasoning}`);
142
+ modelBits.push(`reasoning: ${reasoning}`);
141
143
  modelBits.push(yolo ? "auto-run" : "approve");
142
144
 
143
145
  const up = model.totalPromptTokens;
@@ -152,7 +154,7 @@ function footerLines(agent, model, yolo) {
152
154
 
153
155
  return [
154
156
  C.dim(rule),
155
- C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")),
157
+ modeTag + " " + C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")) + " " + C.dim("opt+←→ model · opt+↑↓ reason · tab: mode"),
156
158
  C.dim(usage),
157
159
  ];
158
160
  }
@@ -162,6 +164,57 @@ const PROMPT_PREFIX = C.green("› "); // live input row (minimal)
162
164
  const HISTORY_PREFIX = C.green("you › "); // echoed into transcript/history
163
165
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
164
166
 
167
+ /**
168
+ * Recognize an Option/Alt + arrow key from a terminal input string.
169
+ * Returns "up" | "down" | "left" | "right" | null.
170
+ *
171
+ * Terminals encode these differently:
172
+ * - xterm modifier form: ESC[1;3A/B/C/D (3 = Alt/Option)
173
+ * - meta word-move (iTerm2 default): ESC b (left word) / ESC f (right word)
174
+ * - some send ESC ESC[A.. (double-ESC) for Alt+arrow
175
+ * When `latched` is true the leading ESC may already have been consumed, so we
176
+ * also accept the tails without the leading ESC.
177
+ */
178
+ function matchOptionArrow(s, latched = false) {
179
+ const arrows = { A: "up", B: "down", C: "right", D: "left" };
180
+ // xterm modifier form with Alt (mod code 3) or Meta (mod code 9).
181
+ let m = s.match(/^\x1b\[1;(?:3|9)([ABCD])$/);
182
+ if (m) return arrows[m[1]];
183
+ // Double-ESC form: ESC ESC [ A..
184
+ m = s.match(/^\x1b\x1b\[([ABCD])$/);
185
+ if (m) return arrows[m[1]];
186
+ // Meta word-move: ESC b / ESC f -> treat as left/right (model cycling).
187
+ if (s === "\x1bb") return "left";
188
+ if (s === "\x1bf") return "right";
189
+ if (latched) {
190
+ // Leading ESC already latched; match the tail we reconstructed as ESC+tail.
191
+ m = s.match(/^\x1b\[1;(?:3|9)([ABCD])$/); // (handled above, kept for clarity)
192
+ if (m) return arrows[m[1]];
193
+ if (s === "\x1bb") return "left";
194
+ if (s === "\x1bf") return "right";
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /** Read the model's current reasoning-effort level ("off" when unset). */
200
+ function currentReasoning(model) {
201
+ const eff = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort;
202
+ return eff || "off";
203
+ }
204
+
205
+ /** Apply a reasoning-effort level to the live model's request kwargs. */
206
+ function setReasoning(model, level) {
207
+ model.modelKwargs = { ...(model.modelKwargs || {}) };
208
+ const l = String(level || "").toLowerCase();
209
+ if (!l || l === "off" || l === "none" || l === "disabled") {
210
+ delete model.modelKwargs.reasoning_effort;
211
+ delete model.modelKwargs.reasoning;
212
+ } else {
213
+ model.modelKwargs.reasoning_effort = l;
214
+ delete model.modelKwargs.reasoning;
215
+ }
216
+ }
217
+
165
218
  /**
166
219
  * A bottom-anchored screen: conversation output scrolls in the top region
167
220
  * (bounded by a DECSTBM scroll margin) while the footer + input prompt stay
@@ -189,8 +242,7 @@ class Screen {
189
242
  this._onResize = this._onResize.bind(this);
190
243
  }
191
244
 
192
- get rows() { return this.out.rows || 24; }
193
- // Footer block (4 rows) pinned to the bottom: divider, prompt, repo/model, usage.
245
+ get rows() { return this.out.rows || 24; } // Footer block (4 rows) pinned to the bottom: divider, prompt, repo/model, usage.
194
246
  get reserved() { return FOOTER_LINES + 1; } // +1 for the prompt row
195
247
  get scrollBottom() { return Math.max(1, this.rows - this.reserved); } // last scrollable row
196
248
  get footerTop() { return this.scrollBottom + 1; } // divider row
@@ -216,6 +268,12 @@ class Screen {
216
268
  this.out.write(`\x1b[${this.rows};1H\n`); // move below footer
217
269
  }
218
270
 
271
+ /** Cancel any pending lone-ESC latch/timer. */
272
+ _clearEscLatch() {
273
+ this._pendingEsc = false;
274
+ clearTimeout(this._escTimer);
275
+ }
276
+
219
277
  /** Write a block of text into the scrolling region (may contain newlines). */
220
278
  log(text) {
221
279
  this.out.write(`\x1b[${this.scrollBottom};1H`); // park at bottom of scroll area
@@ -312,6 +370,86 @@ class Screen {
312
370
  }
313
371
 
314
372
  _onData(chunk) {
373
+ const s = chunk.toString();
374
+ // Optional key debug: set ASTRA_KEYDEBUG=1 to print the raw bytes of every
375
+ // keypress into the transcript. Use it to discover what your terminal
376
+ // actually sends for Option/Alt+Tab, then report it.
377
+ if (process.env.ASTRA_KEYDEBUG && this.log) {
378
+ const bytes = Array.from(chunk).map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ");
379
+ this.log(`\x1b[35m[keydebug] ${bytes}\x1b[0m`);
380
+ }
381
+ // --- Key sequences: mode toggle, model/reasoning cycling, Esc-to-clear ---
382
+ //
383
+ // Mode toggle: Alt/Option+Tab is delivered as ESC+Tab ("\x1b\t"); Shift+Tab
384
+ // ("\x1b[Z") is a reliable fallback. Model/reasoning cycling uses Option +
385
+ // arrow keys. A bare Esc (nothing following it) clears the input line.
386
+ //
387
+ // Because some terminals split escape sequences across reads (a lone ESC,
388
+ // then the rest), we latch a lone ESC briefly. If a Tab follows -> toggle;
389
+ // if nothing follows within the window -> treat it as bare Esc (clear).
390
+
391
+ // Whole-chunk fast paths first.
392
+ if (this.onToggleMode && (s === "\x1b\t" || s === "\x1b[Z")) {
393
+ this._clearEscLatch();
394
+ this.onToggleMode();
395
+ return;
396
+ }
397
+ {
398
+ const opt = matchOptionArrow(s, false);
399
+ if (opt) {
400
+ this._clearEscLatch();
401
+ if ((opt === "left" || opt === "right") && this.onCycleModel) {
402
+ this.onCycleModel(opt === "right" ? 1 : -1);
403
+ return;
404
+ }
405
+ if ((opt === "up" || opt === "down") && this.onCycleReasoning) {
406
+ this.onCycleReasoning(opt === "up" ? 1 : -1);
407
+ return;
408
+ }
409
+ return; // recognized an option-arrow we don't act on; swallow it
410
+ }
411
+ }
412
+
413
+ // A Tab following a latched ESC -> mode toggle (split Alt+Tab delivery).
414
+ if (this._pendingEsc && s === "\t" && this.onToggleMode) {
415
+ this._clearEscLatch();
416
+ this.onToggleMode();
417
+ return;
418
+ }
419
+ // A latched ESC followed by an arrow tail (e.g. "[C", "b", "f") -> option-arrow.
420
+ if (this._pendingEsc) {
421
+ const opt = matchOptionArrow("\x1b" + s, true);
422
+ if (opt) {
423
+ this._clearEscLatch();
424
+ if ((opt === "left" || opt === "right") && this.onCycleModel) {
425
+ this.onCycleModel(opt === "right" ? 1 : -1);
426
+ return;
427
+ }
428
+ if ((opt === "up" || opt === "down") && this.onCycleReasoning) {
429
+ this.onCycleReasoning(opt === "up" ? 1 : -1);
430
+ return;
431
+ }
432
+ return;
433
+ }
434
+ // Any other input right after a latched ESC cancels the latch and is
435
+ // processed normally below.
436
+ this._clearEscLatch();
437
+ }
438
+
439
+ // A lone ESC: latch briefly to disambiguate from split escape sequences.
440
+ // If nothing arrives, the timer treats it as a bare Esc and clears input.
441
+ if (s === "\x1b") {
442
+ this._pendingEsc = true;
443
+ clearTimeout(this._escTimer);
444
+ this._escTimer = setTimeout(() => {
445
+ this._pendingEsc = false;
446
+ if (this.onClearInput) this.onClearInput();
447
+ this.buf = "";
448
+ this.drawPrompt();
449
+ }, 60);
450
+ return;
451
+ }
452
+
315
453
  for (const ch of chunk) {
316
454
  if (ch === "\r" || ch === "\n") {
317
455
  // Collapse a CRLF pair into one Enter: ignore a \n right after a \r.
@@ -370,24 +508,32 @@ Interactive commands:
370
508
  /history print the message history
371
509
  /tokens show token usage so far
372
510
  /yolo toggle auto-run of commands (no confirmation)
511
+
512
+ Keyboard shortcuts:
513
+ alt/opt+tab switch agent / bench mode (shift+tab also works)
514
+ opt+left/right cycle the model
515
+ opt+up/down cycle reasoning effort (off/low/medium/high)
516
+ esc clear the input line
373
517
  `;
374
518
 
375
519
  /**
376
520
  * Run the interactive loop against a prepared Agent.
377
521
  * @param {Agent} agent a started (or restored) agent in interactive mode
378
- * @param {object} opts { model, autoRun }
522
+ * @param {object} opts { model, autoRun, fresh, reasoning, runBench }
523
+ * runBench(taskText, log) => Promise optional; invoked when the user submits
524
+ * a message while the REPL is toggled to bench mode. Should run the task in
525
+ * an isolated bench workspace and record metrics.
379
526
  */
380
- export async function runRepl(agent, { model, autoRun = false, fresh = true } = {}) {
527
+ export async function runRepl(agent, { model, autoRun = false, fresh = true, reasoning = "", runBench = null } = {}) {
381
528
  let yolo = autoRun;
382
529
  const tty = !!process.stdout.isTTY && !!process.stdin.isTTY;
383
530
 
384
531
  const intro = fresh
385
532
  ? bannerLines({ session: agent.sessionId })
386
533
  : [C.dim(`[astra] resumed session ${agent.sessionId} · model=${model.model}`)];
387
- intro.push(C.dim(`Type a request, or /help. Commands ${yolo ? "auto-run" : "need approval"}.`));
388
534
 
389
535
  if (tty) {
390
- await runStickyRepl(agent, model, yolo, intro);
536
+ await runStickyRepl(agent, model, yolo, intro, { runBench });
391
537
  } else {
392
538
  for (const line of intro) console.error(line);
393
539
  await runPlainRepl(agent, model, yolo);
@@ -395,10 +541,40 @@ export async function runRepl(agent, { model, autoRun = false, fresh = true } =
395
541
  }
396
542
 
397
543
  /** Sticky bottom-anchored REPL (real terminals). */
398
- async function runStickyRepl(agent, model, yolo, intro = []) {
544
+ async function runStickyRepl(agent, model, yolo, intro = [], { runBench = null } = {}) {
399
545
  const screen = new Screen();
400
546
  const log = (text) => screen.log(text);
401
- const refresh = () => screen.refresh(footerLines(agent, model, yolo));
547
+ let mode = "agent"; // "agent" | "bench"
548
+ const refresh = () => screen.refresh(footerLines(agent, model, yolo, mode));
549
+
550
+ // Alt/Option+Tab (or Shift+Tab) toggles between agent and bench mode.
551
+ screen.onToggleMode = () => {
552
+ mode = mode === "agent" ? "bench" : "agent";
553
+ refresh();
554
+ };
555
+
556
+ // Option+Left/Right cycles the model; Option+Up/Down cycles reasoning effort.
557
+ // Changes apply to the live model and are cached as the preferred agent
558
+ // prefs so the next `astra` launch reuses them.
559
+ screen.onCycleModel = (dir) => {
560
+ const list = AVAILABLE_MODELS;
561
+ const cur = list.indexOf(model.model);
562
+ const next = ((cur === -1 ? 0 : cur) + dir + list.length) % list.length;
563
+ model.model = list[next];
564
+ saveAgentPrefs({ model: model.model });
565
+ refresh();
566
+ };
567
+ screen.onCycleReasoning = (dir) => {
568
+ const list = REASONING_LEVELS;
569
+ const cur = list.indexOf(currentReasoning(model));
570
+ const next = ((cur === -1 ? 0 : cur) + dir + list.length) % list.length;
571
+ setReasoning(model, list[next]);
572
+ saveAgentPrefs({ reasoning: list[next] });
573
+ refresh();
574
+ };
575
+ // Bare Esc clears the current input line (handled by the screen; nothing
576
+ // extra needed here, but expose a hook for symmetry / future use).
577
+ screen.onClearInput = () => {};
402
578
 
403
579
  // Approval gate: show the command, then ask for y/n/a on the prompt row.
404
580
  agent.confirm = async (command) => {
@@ -436,6 +612,27 @@ async function runStickyRepl(agent, model, yolo, intro = []) {
436
612
 
437
613
  const before = usageSnapshot(model);
438
614
  const t0 = Date.now();
615
+
616
+ // Bench mode: run the message as an autonomous task in an isolated
617
+ // workspace, record metrics, then drop back to agent mode.
618
+ if (mode === "bench") {
619
+ if (!runBench) {
620
+ log(C.red("[astra] bench runner unavailable in this session."));
621
+ mode = "agent"; refresh(); continue;
622
+ }
623
+ screen.startBusy("benching");
624
+ try {
625
+ await runBench(line, log);
626
+ } catch (err) {
627
+ log(C.red(`[astra] bench failed: ${err?.message || err}`));
628
+ } finally {
629
+ screen.stopBusy();
630
+ }
631
+ mode = "agent";
632
+ refresh();
633
+ continue;
634
+ }
635
+
439
636
  agent.addUserMessage(line);
440
637
  screen.startBusy("working");
441
638
  try {