@hackerrank/astra-cli 0.1.25 → 0.1.27

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/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/repl.js +198 -88
package/README.md CHANGED
@@ -82,6 +82,8 @@ astra -m claude-sonnet-5 -y # auto-run commands (no prompts)
82
82
 
83
83
  In-REPL commands: `/help /plan <task> /exit /clear /history /tokens /yolo`.
84
84
 
85
+ Keyboard shortcuts: `Shift+Return` (or `Opt+Return`) for multi-line input, `Alt/Opt+Tab` to toggle bench mode, `Opt+Left/Right` to cycle models, `Opt+Up/Down` to cycle reasoning effort, `Esc` to clear input.
86
+
85
87
  ### Autonomous task run
86
88
 
87
89
  When you pass a task with `-t`/`-f`, astra appends the autonomous rules and runs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackerrank/astra-cli",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Minimal zero-dependency AI coding agent for the HackerRank AI Gateway.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/repl.js CHANGED
@@ -159,7 +159,7 @@ function footerLines(agent, model, yolo, mode = "agent") {
159
159
  C.dim(rule),
160
160
  modeTag + " " + C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")),
161
161
  C.dim(usage),
162
- C.dim("opt+left/right: model · opt+up/down: reasoning · shift+tab: mode"),
162
+ C.dim("shift+return: newline · opt+left/right: model · opt+up/down: reasoning · shift+tab: mode"),
163
163
  ];
164
164
  }
165
165
 
@@ -219,19 +219,76 @@ function setReasoning(model, level) {
219
219
  }
220
220
  }
221
221
 
222
+ /** Strip ANSI color/style escape codes to calculate visual string lengths. */
223
+ export function stripAnsi(str) {
224
+ return String(str || "").replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
225
+ }
226
+
227
+ /**
228
+ * Break an input buffer (with potential newlines and line wraps) into
229
+ * visual lines suitable for terminal display.
230
+ */
231
+ export function computeVisualLines(buf, promptPrefix, cols = 80) {
232
+ const logicalLines = String(buf || "").split("\n");
233
+ const visualLines = [];
234
+ const prefixVisible = stripAnsi(promptPrefix);
235
+ const contPrefix = " ";
236
+ const contPrefixVisible = " ";
237
+
238
+ for (let i = 0; i < logicalLines.length; i++) {
239
+ const p = i === 0 ? promptPrefix : contPrefix;
240
+ const pVis = i === 0 ? prefixVisible : contPrefixVisible;
241
+ const line = logicalLines[i];
242
+ const full = p + line;
243
+ const fullVis = pVis + line;
244
+
245
+ if (fullVis.length <= cols || cols <= 0) {
246
+ visualLines.push({ text: full, visibleLength: fullVis.length });
247
+ } else {
248
+ let remaining = line;
249
+ let first = true;
250
+ while (remaining.length > 0 || first) {
251
+ const currentP = first ? p : contPrefix;
252
+ const currentPVis = first ? pVis : contPrefixVisible;
253
+ const availableWidth = Math.max(1, cols - currentPVis.length);
254
+ const take = remaining.slice(0, availableWidth);
255
+ remaining = remaining.slice(availableWidth);
256
+ visualLines.push({ text: currentP + take, visibleLength: currentPVis.length + take.length });
257
+ first = false;
258
+ if (remaining.length === 0) break;
259
+ }
260
+ }
261
+ }
262
+ return visualLines.length > 0 ? visualLines : [{ text: promptPrefix, visibleLength: prefixVisible.length }];
263
+ }
264
+
265
+ /** Check if an escape sequence or key code is a newline-insertion shortcut (Shift+Enter, Option+Enter, etc.). */
266
+ export function isNewlineKey(s, pendingEsc = false) {
267
+ if (!s || typeof s !== "string") return false;
268
+ if (/^\x1b\[(?:13|10);[2-8]u$/.test(s)) return true;
269
+ if (/^\x1b\[27;[2-8];(?:13|10)~$/.test(s)) return true;
270
+ if (/^\x1b\[(?:13|10);[2-8]~$/.test(s)) return true;
271
+ if (s === "\x1bOM") return true;
272
+ if (s === "\x1b\r" || s === "\x1b\n" || s === "\x1b\x0d") return true;
273
+ if (pendingEsc && (s === "\r" || s === "\n" || s === "\x0d" || s === "\x0a")) return true;
274
+ if (s === "\x0a" || s === "\n") return true;
275
+ return false;
276
+ }
277
+
222
278
  /**
223
279
  * A bottom-anchored screen: conversation output scrolls in the top region
224
280
  * (bounded by a DECSTBM scroll margin) while the footer + input prompt stay
225
281
  * glued to the terminal's bottom rows. Input is read via raw keypresses (not
226
- * readline) so nothing fights the scroll margin. Requires a TTY; callers use
227
- * the readline fallback otherwise.
282
+ * readline) so nothing fights the scroll margin. Multi-line input and wrapped
283
+ * typing adjust the scroll margin dynamically to prevent overwriting status
284
+ * lines or history. Requires a TTY; callers use the readline fallback otherwise.
228
285
  */
229
- class Screen {
286
+ export class Screen {
230
287
  constructor(out = process.stdout, inp = process.stdin) {
231
288
  this.out = out;
232
289
  this.inp = inp;
233
290
  this.footer = [];
234
- this.buf = ""; // current input line
291
+ this.buf = ""; // current input buffer (can contain newlines)
235
292
  this.resolve = null; // pending readLine() resolver
236
293
  this._lastWasCR = false;
237
294
  this._guardEnterUntil = 0; // ignore stray Enter until this timestamp
@@ -241,21 +298,39 @@ class Screen {
241
298
  this._busyFrame = 0;
242
299
  this._busyLabel = "working";
243
300
  this._busyResume = false;
301
+ this._prevScrollBottom = 0;
302
+ this._prevFooterTop = 0;
303
+ this._inPaste = false;
244
304
  this.promptLabel = PROMPT_PREFIX;
245
305
  this._onData = this._onData.bind(this);
246
306
  this._onResize = this._onResize.bind(this);
247
307
  }
248
308
 
249
- get rows() { return this.out.rows || 24; } // Footer block (4 rows) pinned to the bottom: divider, prompt, repo/model, usage.
250
- get reserved() { return FOOTER_LINES + 1; } // +1 for the prompt row
251
- get scrollBottom() { return Math.max(1, this.rows - this.reserved); } // last scrollable row
252
- get footerTop() { return this.scrollBottom + 1; } // divider row
253
- get promptRow() { return this.footerTop + 1; } // input row: right under the divider
309
+ get cols() { return this.out.columns || 80; }
310
+ get rows() { return this.out.rows || 24; }
311
+
312
+ get promptLines() {
313
+ if (this._busy) return 1;
314
+ const visual = computeVisualLines(this.buf, this.promptLabel || PROMPT_PREFIX, this.cols);
315
+ const maxLines = Math.max(1, Math.floor((this.rows - FOOTER_LINES) / 2));
316
+ return Math.min(visual.length, maxLines);
317
+ }
318
+
319
+ get reserved() { return FOOTER_LINES + this.promptLines; }
320
+ get scrollBottom() { return Math.max(1, this.rows - this.reserved); }
321
+ get footerTop() { return this.scrollBottom + 1; }
322
+ get promptRow() { return this.footerTop + 1; }
323
+ get statusStartRow() { return this.promptRow + this.promptLines; }
254
324
 
255
325
  /** Enter sticky mode: set scroll margin, park cursor, start listening. */
256
326
  enter() {
327
+ this._prevScrollBottom = this.scrollBottom;
328
+ this._prevFooterTop = this.footerTop;
257
329
  this.out.write(`\x1b[1;${this.scrollBottom}r`); // scroll region = top area
258
330
  this.out.write(`\x1b[${this.scrollBottom};1H`); // cursor at bottom of scroll area
331
+ this.out.write("\x1b[?2004h"); // enable bracketed paste mode
332
+ this.out.write("\x1b[>4;2m"); // enable modifyOtherKeys
333
+ this.out.write("\x1b[=1u"); // enable kitty keyboard protocol
259
334
  if (this.inp.isTTY) this.inp.setRawMode(true);
260
335
  this.inp.resume();
261
336
  this.inp.setEncoding("utf8");
@@ -268,6 +343,9 @@ class Screen {
268
343
  this.inp.off("data", this._onData);
269
344
  this.out.off("resize", this._onResize);
270
345
  if (this.inp.isTTY) this.inp.setRawMode(false);
346
+ this.out.write("\x1b[>4;0m"); // disable modifyOtherKeys
347
+ this.out.write("\x1b[=0u"); // disable kitty keyboard protocol
348
+ this.out.write("\x1b[?2004l"); // disable bracketed paste mode
271
349
  this.out.write("\x1b[r"); // reset scroll region
272
350
  this.out.write(`\x1b[${this.rows};1H\n`); // move below footer
273
351
  }
@@ -281,41 +359,78 @@ class Screen {
281
359
  /** Write a block of text into the scrolling region (may contain newlines). */
282
360
  log(text) {
283
361
  this.out.write(`\x1b[${this.scrollBottom};1H`); // park at bottom of scroll area
284
- this.out.write(String(text) + "\n"); // trailing \n scrolls the region
285
- this.drawFooter();
286
- this.drawPrompt();
362
+ const formatted = String(text).replace(/\r?\n/g, "\r\n") + "\r\n";
363
+ this.out.write(formatted);
364
+ this.redraw();
287
365
  }
288
366
 
289
367
  /** Redraw the pinned footer + prompt (e.g. when usage changes). */
290
368
  refresh(footer) {
291
369
  if (footer) this.footer = footer;
292
- this.drawFooter();
293
- this.drawPrompt();
370
+ this.redraw();
294
371
  }
295
372
 
296
- drawFooter() {
373
+ /** Unified redraw of footer divider, multi-line prompt, and status lines without overwriting. */
374
+ redraw() {
375
+ if (this._prevScrollBottom !== this.scrollBottom) {
376
+ this.out.write(`\x1b[1;${this.scrollBottom}r`);
377
+ this._prevScrollBottom = this.scrollBottom;
378
+ }
379
+
297
380
  this.out.write("\x1b[s"); // save cursor
298
- // footer[0] = divider (row footerTop), footer[1..] = status lines that go
299
- // BELOW the prompt row (footerTop+1). So skip the prompt row when placing
300
- // the status lines.
301
- this.out.write(`\x1b[${this.footerTop};1H\x1b[2K`);
302
- this.out.write(this.footer[0] || ""); // divider
303
- for (let i = 1; i < FOOTER_LINES; i++) {
304
- const row = this.promptRow + i; // status lines under the prompt
305
- this.out.write(`\x1b[${row};1H\x1b[2K`);
306
- this.out.write(this.footer[i] || "");
381
+
382
+ // Clear only from footerTop down to terminal bottom
383
+ for (let r = this.footerTop; r <= this.rows; r++) {
384
+ this.out.write(`\x1b[${r};1H\x1b[2K`);
307
385
  }
308
- this.out.write("\x1b[u"); // restore cursor
309
- }
310
386
 
311
- drawPrompt() {
312
- this.out.write(`\x1b[${this.promptRow};1H\x1b[2K`);
387
+ // Draw top divider rule at footerTop
388
+ this.out.write(`\x1b[${this.footerTop};1H`);
389
+ this.out.write(this.footer[0] || "");
390
+
391
+ // Draw prompt (or busy spinner)
313
392
  if (this._busy) {
393
+ this.out.write(`\x1b[${this.promptRow};1H`);
314
394
  const frame = SPINNER_FRAMES[this._busyFrame % SPINNER_FRAMES.length];
315
395
  this.out.write(C.cyan(frame + " ") + C.dim(this._busyLabel + "…"));
316
- return;
396
+ } else {
397
+ const visual = computeVisualLines(this.buf, this.promptLabel || PROMPT_PREFIX, this.cols);
398
+ const visibleLines = visual.slice(-this.promptLines);
399
+ for (let i = 0; i < visibleLines.length; i++) {
400
+ const row = this.promptRow + i;
401
+ this.out.write(`\x1b[${row};1H`);
402
+ this.out.write(visibleLines[i].text);
403
+ }
317
404
  }
318
- this.out.write((this.promptLabel || PROMPT_PREFIX) + this.buf);
405
+
406
+ // Draw status lines below the prompt
407
+ for (let i = 1; i < FOOTER_LINES; i++) {
408
+ const row = this.statusStartRow + i - 1;
409
+ if (row <= this.rows) {
410
+ this.out.write(`\x1b[${row};1H`);
411
+ this.out.write(this.footer[i] || "");
412
+ }
413
+ }
414
+
415
+ // Place cursor at the end of input
416
+ if (!this._busy) {
417
+ const visual = computeVisualLines(this.buf, this.promptLabel || PROMPT_PREFIX, this.cols);
418
+ const visibleLines = visual.slice(-this.promptLines);
419
+ const lastLine = visibleLines[visibleLines.length - 1] || { visibleLength: 0 };
420
+ const cursorRow = this.promptRow + (visibleLines.length - 1);
421
+ const cursorCol = Math.min(this.cols, lastLine.visibleLength + 1);
422
+ this.out.write(`\x1b[${cursorRow};${cursorCol}H`);
423
+ } else {
424
+ this.out.write("\x1b[u"); // restore cursor
425
+ }
426
+ }
427
+
428
+ drawFooter() {
429
+ this.redraw();
430
+ }
431
+
432
+ drawPrompt() {
433
+ this.redraw();
319
434
  }
320
435
 
321
436
  /** Start the minimal loading state on the prompt row. */
@@ -323,11 +438,11 @@ class Screen {
323
438
  this._busy = true;
324
439
  this._busyLabel = label;
325
440
  this._busyFrame = 0;
326
- this.drawPrompt();
441
+ this.redraw();
327
442
  if (this._busyTimer) clearInterval(this._busyTimer);
328
443
  this._busyTimer = setInterval(() => {
329
444
  this._busyFrame++;
330
- this.drawPrompt();
445
+ this.redraw();
331
446
  }, 90);
332
447
  if (this._busyTimer.unref) this._busyTimer.unref();
333
448
  }
@@ -337,7 +452,7 @@ class Screen {
337
452
  if (this._busyTimer) { clearInterval(this._busyTimer); this._busyTimer = null; }
338
453
  this._busy = false;
339
454
  this._busyResume = false;
340
- this.drawPrompt();
455
+ this.redraw();
341
456
  }
342
457
 
343
458
  /** True while the agent is working (a turn is in progress). */
@@ -349,16 +464,9 @@ class Screen {
349
464
  * Resolve with the next full line the user types. An optional prompt label
350
465
  * replaces the default "you › " (used by the approval gate so the question
351
466
  * is shown right where the user is typing).
352
- *
353
- * `opts.guardEnter` ignores a bare Enter that lands within a short window of
354
- * the prompt appearing. This prevents a stray newline still in the input
355
- * buffer (from the previous line, a CRLF pair, or a paste) from instantly
356
- * "answering" a confirmation prompt the user never actually saw.
357
467
  */
358
468
  readLine(promptLabel, opts = {}) {
359
469
  return new Promise((res) => {
360
- // A prompt (main input or approval) takes over the row; pause the spinner
361
- // timer but remember whether we were busy so we can resume after.
362
470
  this._busyResume = this._busy;
363
471
  if (this._busyTimer) { clearInterval(this._busyTimer); this._busyTimer = null; }
364
472
  this._busy = false;
@@ -367,37 +475,50 @@ class Screen {
367
475
  this.resolve = res;
368
476
  this._guardEnterUntil = opts.guardEnter ? Date.now() + 250 : 0;
369
477
  this._echoOnCommit = opts.echo !== false;
370
- this.drawPrompt();
478
+ this.redraw();
371
479
  });
372
480
  }
373
481
 
374
482
  _onResize() {
375
- this.out.write(`\x1b[1;${this.scrollBottom}r`);
376
- this.footer = this.footer; // caller refreshes content separately
377
- this.drawFooter();
378
- this.drawPrompt();
483
+ this.redraw();
379
484
  }
380
485
 
381
486
  _onData(chunk) {
382
487
  const s = chunk.toString();
383
- // Optional key debug: set ASTRA_KEYDEBUG=1 to print the raw bytes of every
384
- // keypress into the transcript. Use it to discover what your terminal
385
- // actually sends for Option/Alt+Tab, then report it.
386
488
  if (process.env.ASTRA_KEYDEBUG && this.log) {
387
489
  const bytes = Array.from(chunk).map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ");
388
490
  this.log(`\x1b[35m[keydebug] ${bytes}\x1b[0m`);
389
491
  }
390
- // --- Key sequences: mode toggle, model/reasoning cycling, Esc-to-clear ---
391
- //
392
- // Mode toggle: Alt/Option+Tab is delivered as ESC+Tab ("\x1b\t"); Shift+Tab
393
- // ("\x1b[Z") is a reliable fallback. Model/reasoning cycling uses Option +
394
- // arrow keys. A bare Esc (nothing following it) clears the input line.
395
- //
396
- // Because some terminals split escape sequences across reads (a lone ESC,
397
- // then the rest), we latch a lone ESC briefly. If a Tab follows -> toggle;
398
- // if nothing follows within the window -> treat it as bare Esc (clear).
399
-
400
- // Whole-chunk fast paths first.
492
+
493
+ // Bracketed paste handling (pasting multi-line blocks into the input)
494
+ let str = s;
495
+ if (str.includes("\x1b[200~") || this._inPaste) {
496
+ if (str.includes("\x1b[200~")) {
497
+ this._inPaste = true;
498
+ str = str.slice(str.indexOf("\x1b[200~") + 6);
499
+ }
500
+ if (str.includes("\x1b[201~")) {
501
+ const end = str.indexOf("\x1b[201~");
502
+ const pasted = str.slice(0, end);
503
+ this.buf += pasted.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
504
+ this._inPaste = false;
505
+ this.redraw();
506
+ return;
507
+ }
508
+ this.buf += str.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
509
+ this.redraw();
510
+ return;
511
+ }
512
+
513
+ // Newline insertion shortcut (Shift+Enter, Option+Enter, Alt+Enter, Ctrl+Enter, Esc+Enter)
514
+ if (isNewlineKey(s, this._pendingEsc)) {
515
+ this._clearEscLatch();
516
+ this.buf += "\n";
517
+ this.redraw();
518
+ return;
519
+ }
520
+
521
+ // Mode toggle: Alt/Option+Tab or Shift+Tab
401
522
  if (this.onToggleMode && (s === "\x1b\t" || s === "\x1b[Z")) {
402
523
  this._clearEscLatch();
403
524
  this.onToggleMode();
@@ -415,17 +536,17 @@ class Screen {
415
536
  this.onCycleReasoning(opt === "up" ? 1 : -1);
416
537
  return;
417
538
  }
418
- return; // recognized an option-arrow we don't act on; swallow it
539
+ return;
419
540
  }
420
541
  }
421
542
 
422
- // A Tab following a latched ESC -> mode toggle (split Alt+Tab delivery).
543
+ // A Tab following a latched ESC -> mode toggle
423
544
  if (this._pendingEsc && s === "\t" && this.onToggleMode) {
424
545
  this._clearEscLatch();
425
546
  this.onToggleMode();
426
547
  return;
427
548
  }
428
- // A latched ESC followed by an arrow tail (e.g. "[C", "b", "f") -> option-arrow.
549
+ // A latched ESC followed by an arrow tail -> option-arrow
429
550
  if (this._pendingEsc) {
430
551
  const opt = matchOptionArrow("\x1b" + s, true);
431
552
  if (opt) {
@@ -440,13 +561,10 @@ class Screen {
440
561
  }
441
562
  return;
442
563
  }
443
- // Any other input right after a latched ESC cancels the latch and is
444
- // processed normally below.
445
564
  this._clearEscLatch();
446
565
  }
447
566
 
448
- // A lone ESC: latch briefly to disambiguate from split escape sequences.
449
- // If nothing arrives, the timer treats it as a bare Esc and clears input.
567
+ // A lone ESC: latch briefly to disambiguate from split escape sequences
450
568
  if (s === "\x1b") {
451
569
  this._pendingEsc = true;
452
570
  clearTimeout(this._escTimer);
@@ -454,19 +572,15 @@ class Screen {
454
572
  this._pendingEsc = false;
455
573
  if (this.onClearInput) this.onClearInput();
456
574
  this.buf = "";
457
- this.drawPrompt();
575
+ this.redraw();
458
576
  }, 60);
459
577
  return;
460
578
  }
461
579
 
462
- for (const ch of chunk) {
580
+ for (const ch of s) {
463
581
  if (ch === "\r" || ch === "\n") {
464
- // Collapse a CRLF pair into one Enter: ignore a \n right after a \r.
465
582
  if (ch === "\n" && this._lastWasCR) { this._lastWasCR = false; continue; }
466
583
  this._lastWasCR = ch === "\r";
467
- // Guard: ignore a bare Enter (empty buffer) that arrives immediately
468
- // after a guarded prompt was shown. This stops a leftover newline from
469
- // auto-resolving a confirmation the user never got to answer.
470
584
  if (
471
585
  this.buf === "" &&
472
586
  this._guardEnterUntil &&
@@ -474,30 +588,25 @@ class Screen {
474
588
  ) {
475
589
  continue;
476
590
  }
477
- const line = this.buf;
591
+ const text = this.buf;
478
592
  this.buf = "";
479
- // Echo the committed line into the scroll area for a transcript.
480
- // User input is echoed with the "you › " history prefix; approval
481
- // prompts pass echo:false so their Q&A stays out of the transcript.
482
- // Empty input is skipped so a bare Enter doesn't stack blank lines.
483
- if (this._echoOnCommit && line.trim() !== "") {
593
+ if (this._echoOnCommit && text.trim() !== "") {
484
594
  const isUserPrompt = (this.promptLabel || PROMPT_PREFIX) === PROMPT_PREFIX;
485
- this.log((isUserPrompt ? HISTORY_PREFIX : this.promptLabel) + line);
595
+ const prefix = isUserPrompt ? HISTORY_PREFIX : this.promptLabel;
596
+ const lines = text.split("\n");
597
+ for (let i = 0; i < lines.length; i++) {
598
+ this.log((i === 0 ? prefix : " ") + lines[i]);
599
+ }
486
600
  }
487
601
  this.promptLabel = PROMPT_PREFIX;
488
602
  this._echoOnCommit = true;
489
- // Resume the loading state if a turn is still in progress (e.g. after
490
- // an approval prompt hands control back to the running agent).
491
603
  if (this._busyResume) { this._busyResume = false; this.startBusy(this._busyLabel); }
492
604
  const r = this.resolve; this.resolve = null;
493
- if (r) r(line);
605
+ if (r) r(text);
494
606
  } else if (ch === "\x7f" || ch === "\b") { // backspace
495
607
  this.buf = this.buf.slice(0, -1);
496
- this.drawPrompt();
608
+ this.redraw();
497
609
  } else if (ch === "\x03") { // Ctrl+C
498
- // If a prompt is pending (idle), resolve it so the loop can react.
499
- // Also always notify the interrupt hook so a *running* agent turn can
500
- // be aborted even when no readLine is pending.
501
610
  const r = this.resolve; this.resolve = null;
502
611
  if (r) r("__SIGINT__");
503
612
  if (this.onInterrupt) this.onInterrupt();
@@ -507,7 +616,7 @@ class Screen {
507
616
  } else if (ch >= " ") { // printable
508
617
  this._lastWasCR = false;
509
618
  this.buf += ch;
510
- this.drawPrompt();
619
+ this.redraw();
511
620
  }
512
621
  }
513
622
  }
@@ -524,6 +633,7 @@ Interactive commands:
524
633
  /yolo toggle auto-run of commands (no confirmation)
525
634
 
526
635
  Keyboard shortcuts:
636
+ shift+return insert a newline for multi-line input (alt/opt+enter also works)
527
637
  alt/opt+tab switch agent / bench mode (shift+tab also works)
528
638
  opt+left/right cycle the model
529
639
  opt+up/down cycle reasoning effort (off/low/medium/high)