@hackerrank/astra-cli 0.1.24 → 0.1.26

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/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.24",
3
+ "version": "0.1.26",
4
4
  "description": "Minimal zero-dependency AI coding agent for the HackerRank AI Gateway.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/model.js CHANGED
@@ -42,7 +42,7 @@ export class GatewayModel {
42
42
  * @param {number} [opts.maxRetries]
43
43
  * @param {(info:object)=>void} [opts.onRetry] called before each retry sleep
44
44
  */
45
- constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 5, maxTokens = 8192, requestTimeoutMs = 0, onRetry } = {}) {
45
+ constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 8, maxTokens = 8192, requestTimeoutMs = 0, onRetry } = {}) {
46
46
  if (!model) throw new Error("GatewayModel: `model` is required");
47
47
  this.model = model;
48
48
  this.maxTokens = maxTokens;
@@ -124,14 +124,15 @@ export class GatewayModel {
124
124
  // Retry on transient server / rate-limit errors with exponential
125
125
  // backoff (honoring Retry-After when the server provides it).
126
126
  if ((res.status === 429 || res.status >= 500) && attempt < this.maxRetries) {
127
- const wait = retryAfterMs(res.headers) ?? backoffMs(attempt);
127
+ const serverWait = retryAfterMs(res.headers, text);
128
+ const wait = serverWait != null ? Math.max(serverWait + 250, 1000) : backoffMs(attempt);
128
129
  this.nRetries++;
129
130
  this.onRetry({
130
131
  attempt: attempt + 1,
131
132
  maxRetries: this.maxRetries,
132
133
  status: res.status,
133
134
  waitMs: wait,
134
- reason: `HTTP ${res.status}`,
135
+ reason: res.status === 429 ? "HTTP 429 (rate limited)" : `HTTP ${res.status}`,
135
136
  });
136
137
  await sleep(wait);
137
138
  continue;
@@ -282,14 +283,180 @@ function hintForStatus(status) {
282
283
  }
283
284
  }
284
285
 
285
- /** Parse a Retry-After header (seconds or HTTP-date) into milliseconds. */
286
- function retryAfterMs(headers) {
287
- const v = headers?.get?.("retry-after");
288
- if (!v) return null;
289
- const secs = Number(v);
290
- if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
291
- const when = Date.parse(v);
292
- if (Number.isFinite(when)) return Math.max(0, when - Date.now());
286
+ /**
287
+ * Parse a duration string, number, or date into milliseconds.
288
+ * Supports numbers (seconds or timestamps), units (ms, s, m, h, d),
289
+ * composite durations ("1m 30s"), and HTTP/ISO dates.
290
+ */
291
+ export function parseDurationMs(val) {
292
+ if (typeof val === "number" && Number.isFinite(val) && val >= 0) {
293
+ if (val > 1e11) return Math.max(0, val - Date.now());
294
+ if (val > 1e9) return Math.max(0, val * 1000 - Date.now());
295
+ return Math.max(0, Math.round(val * 1000));
296
+ }
297
+ if (!val || typeof val !== "string") return null;
298
+ const s = val.trim();
299
+
300
+ const rawNum = Number(s);
301
+ if (Number.isFinite(rawNum) && rawNum >= 0) {
302
+ if (rawNum > 1e11) return Math.max(0, rawNum - Date.now());
303
+ if (rawNum > 1e9) return Math.max(0, rawNum * 1000 - Date.now());
304
+ return Math.max(0, Math.round(rawNum * 1000));
305
+ }
306
+
307
+ const when = Date.parse(s);
308
+ if (Number.isFinite(when) && when > Date.now()) {
309
+ return Math.max(0, when - Date.now());
310
+ }
311
+
312
+ const pattern = /([0-9]+(?:\.[0-9]+)?)\s*(milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/gi;
313
+ let match;
314
+ let totalMs = 0;
315
+ let matched = false;
316
+ while ((match = pattern.exec(s)) !== null) {
317
+ matched = true;
318
+ const n = parseFloat(match[1]);
319
+ const unit = match[2].toLowerCase();
320
+ if (!Number.isFinite(n)) continue;
321
+ if (unit.startsWith("ms") || unit.startsWith("milli")) {
322
+ totalMs += n;
323
+ } else if (unit.startsWith("s")) {
324
+ totalMs += n * 1000;
325
+ } else if (unit.startsWith("m")) {
326
+ totalMs += n * 60 * 1000;
327
+ } else if (unit.startsWith("h")) {
328
+ totalMs += n * 3600 * 1000;
329
+ } else if (unit.startsWith("d")) {
330
+ totalMs += n * 86400 * 1000;
331
+ }
332
+ }
333
+ return matched ? Math.round(totalMs) : null;
334
+ }
335
+
336
+ /**
337
+ * Extract retry duration from unstructured error message text.
338
+ * Matches phrases like "retry after 12.5s", "try again in 5 seconds", "resets in 30s", "wait 10s".
339
+ */
340
+ export function extractDurationFromText(text) {
341
+ if (!text || typeof text !== "string") return null;
342
+
343
+ const p1 = /(?:retry(?:ing)?|try\s+again|wait(?:ing)?|resets?|available|back\s*off)\s+(?:again\s+)?(?:after|in|for)\s+([0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b(?:\s+[0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b)*|[0-9]+(?:\.[0-9]+)?)/i;
344
+ const m1 = text.match(p1);
345
+ if (m1) {
346
+ const parsed = parseDurationMs(m1[1]);
347
+ if (parsed != null) return parsed;
348
+ }
349
+
350
+ const p2 = /(?:wait(?:ing)?)\s+([0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b)/i;
351
+ const m2 = text.match(p2);
352
+ if (m2) {
353
+ const parsed = parseDurationMs(m2[1]);
354
+ if (parsed != null) return parsed;
355
+ }
356
+
357
+ const p3 = /in\s+([0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b)\s*[,.]?\s*(?:please\s+)?(?:retry|try)/i;
358
+ const m3 = text.match(p3);
359
+ if (m3) {
360
+ const parsed = parseDurationMs(m3[1]);
361
+ if (parsed != null) return parsed;
362
+ }
363
+
364
+ return null;
365
+ }
366
+
367
+ /**
368
+ * Parse retry delay from response headers or error response body into milliseconds.
369
+ * Checks standard and provider-specific rate-limit headers as well as JSON fields
370
+ * and error messages returned by AI gateways (HackerRank, OpenAI, Anthropic, LiteLLM, Gemini, etc.).
371
+ */
372
+ export function retryAfterMs(headers, bodyText) {
373
+ const msHeader = headers?.get?.("retry-after-ms");
374
+ if (msHeader) {
375
+ const ms = Number(msHeader);
376
+ if (Number.isFinite(ms) && ms >= 0) return Math.round(ms);
377
+ }
378
+
379
+ const headerKeys = [
380
+ "retry-after",
381
+ "x-retry-after",
382
+ "x-ratelimit-reset-requests",
383
+ "x-ratelimit-reset-tokens",
384
+ "x-ratelimit-reset",
385
+ ];
386
+ for (const k of headerKeys) {
387
+ const val = headers?.get?.(k);
388
+ if (val) {
389
+ const parsed = parseDurationMs(val);
390
+ if (parsed != null) return parsed;
391
+ }
392
+ }
393
+
394
+ if (bodyText) {
395
+ let json = null;
396
+ if (typeof bodyText === "object") {
397
+ json = bodyText;
398
+ } else if (typeof bodyText === "string") {
399
+ try {
400
+ json = JSON.parse(bodyText);
401
+ } catch {}
402
+ }
403
+
404
+ if (json && typeof json === "object") {
405
+ const msCandidates = [json.retry_after_ms, json.error?.retry_after_ms];
406
+ for (const c of msCandidates) {
407
+ if (Number.isFinite(Number(c)) && Number(c) >= 0) {
408
+ return Math.round(Number(c));
409
+ }
410
+ }
411
+
412
+ const durationCandidates = [
413
+ json.retry_after,
414
+ json.retryAfter,
415
+ json.retry_after_seconds,
416
+ json.retry_after_sec,
417
+ json.reset_in,
418
+ json.reset_after,
419
+ json.reset_at,
420
+ json.wait_seconds,
421
+ json.wait_time,
422
+ json.error?.retry_after,
423
+ json.error?.retryAfter,
424
+ json.error?.retry_after_seconds,
425
+ json.error?.retry_after_sec,
426
+ json.error?.reset_in,
427
+ json.error?.reset_after,
428
+ json.error?.reset_at,
429
+ json.error?.wait_seconds,
430
+ json.error?.wait_time,
431
+ ];
432
+ for (const c of durationCandidates) {
433
+ if (c != null) {
434
+ const parsed = parseDurationMs(c);
435
+ if (parsed != null) return parsed;
436
+ }
437
+ }
438
+
439
+ const msgCandidates = [
440
+ json.error?.message,
441
+ typeof json.error === "string" ? json.error : null,
442
+ json.message,
443
+ json.detail,
444
+ typeof json.details === "string" ? json.details : null,
445
+ ];
446
+ for (const msg of msgCandidates) {
447
+ if (msg) {
448
+ const extracted = extractDurationFromText(msg);
449
+ if (extracted != null) return extracted;
450
+ }
451
+ }
452
+ }
453
+
454
+ if (typeof bodyText === "string") {
455
+ const extracted = extractDurationFromText(bodyText);
456
+ if (extracted != null) return extracted;
457
+ }
458
+ }
459
+
293
460
  return null;
294
461
  }
295
462
 
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,73 @@ 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 === "\x1b[13;2u" || s === "\x1b[27;2;13~" || s === "\x1b[13;2~" || s === "\x1bOM") return true; // Shift+Enter
268
+ if (s === "\x1b[13;3u" || s === "\x1b[27;3;13~" || s === "\x1b\r" || s === "\x1b\n") return true; // Alt/Option+Enter
269
+ if (s === "\x1b[13;5u" || s === "\x1b[27;5;13~") return true; // Ctrl+Enter
270
+ if (pendingEsc && (s === "\r" || s === "\n")) return true; // Esc then Enter
271
+ if (s === "\x0a") return true; // Ctrl+J (LF)
272
+ return false;
273
+ }
274
+
222
275
  /**
223
276
  * A bottom-anchored screen: conversation output scrolls in the top region
224
277
  * (bounded by a DECSTBM scroll margin) while the footer + input prompt stay
225
278
  * 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.
279
+ * readline) so nothing fights the scroll margin. Multi-line input and wrapped
280
+ * typing adjust the scroll margin dynamically to prevent overwriting status
281
+ * lines or history. Requires a TTY; callers use the readline fallback otherwise.
228
282
  */
229
- class Screen {
283
+ export class Screen {
230
284
  constructor(out = process.stdout, inp = process.stdin) {
231
285
  this.out = out;
232
286
  this.inp = inp;
233
287
  this.footer = [];
234
- this.buf = ""; // current input line
288
+ this.buf = ""; // current input buffer (can contain newlines)
235
289
  this.resolve = null; // pending readLine() resolver
236
290
  this._lastWasCR = false;
237
291
  this._guardEnterUntil = 0; // ignore stray Enter until this timestamp
@@ -241,21 +295,37 @@ class Screen {
241
295
  this._busyFrame = 0;
242
296
  this._busyLabel = "working";
243
297
  this._busyResume = false;
298
+ this._prevScrollBottom = 0;
299
+ this._prevFooterTop = 0;
300
+ this._inPaste = false;
244
301
  this.promptLabel = PROMPT_PREFIX;
245
302
  this._onData = this._onData.bind(this);
246
303
  this._onResize = this._onResize.bind(this);
247
304
  }
248
305
 
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
306
+ get cols() { return this.out.columns || 80; }
307
+ get rows() { return this.out.rows || 24; }
308
+
309
+ get promptLines() {
310
+ if (this._busy) return 1;
311
+ const visual = computeVisualLines(this.buf, this.promptLabel || PROMPT_PREFIX, this.cols);
312
+ const maxLines = Math.max(1, Math.floor((this.rows - FOOTER_LINES) / 2));
313
+ return Math.min(visual.length, maxLines);
314
+ }
315
+
316
+ get reserved() { return FOOTER_LINES + this.promptLines; }
317
+ get scrollBottom() { return Math.max(1, this.rows - this.reserved); }
318
+ get footerTop() { return this.scrollBottom + 1; }
319
+ get promptRow() { return this.footerTop + 1; }
320
+ get statusStartRow() { return this.promptRow + this.promptLines; }
254
321
 
255
322
  /** Enter sticky mode: set scroll margin, park cursor, start listening. */
256
323
  enter() {
324
+ this._prevScrollBottom = this.scrollBottom;
325
+ this._prevFooterTop = this.footerTop;
257
326
  this.out.write(`\x1b[1;${this.scrollBottom}r`); // scroll region = top area
258
327
  this.out.write(`\x1b[${this.scrollBottom};1H`); // cursor at bottom of scroll area
328
+ this.out.write("\x1b[?2004h"); // enable bracketed paste mode
259
329
  if (this.inp.isTTY) this.inp.setRawMode(true);
260
330
  this.inp.resume();
261
331
  this.inp.setEncoding("utf8");
@@ -268,6 +338,7 @@ class Screen {
268
338
  this.inp.off("data", this._onData);
269
339
  this.out.off("resize", this._onResize);
270
340
  if (this.inp.isTTY) this.inp.setRawMode(false);
341
+ this.out.write("\x1b[?2004l"); // disable bracketed paste mode
271
342
  this.out.write("\x1b[r"); // reset scroll region
272
343
  this.out.write(`\x1b[${this.rows};1H\n`); // move below footer
273
344
  }
@@ -282,40 +353,78 @@ class Screen {
282
353
  log(text) {
283
354
  this.out.write(`\x1b[${this.scrollBottom};1H`); // park at bottom of scroll area
284
355
  this.out.write(String(text) + "\n"); // trailing \n scrolls the region
285
- this.drawFooter();
286
- this.drawPrompt();
356
+ this.redraw();
287
357
  }
288
358
 
289
359
  /** Redraw the pinned footer + prompt (e.g. when usage changes). */
290
360
  refresh(footer) {
291
361
  if (footer) this.footer = footer;
292
- this.drawFooter();
293
- this.drawPrompt();
362
+ this.redraw();
294
363
  }
295
364
 
296
- drawFooter() {
365
+ /** Unified redraw of footer divider, multi-line prompt, and status lines without overwriting. */
366
+ redraw() {
367
+ if (this._prevScrollBottom !== this.scrollBottom) {
368
+ this.out.write(`\x1b[1;${this.scrollBottom}r`);
369
+ this._prevScrollBottom = this.scrollBottom;
370
+ }
371
+
297
372
  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] || "");
373
+
374
+ // Clear old footer/prompt lines down to terminal bottom
375
+ const clearFrom = Math.min(this._prevFooterTop || this.footerTop, this.footerTop);
376
+ for (let r = clearFrom; r <= this.rows; r++) {
377
+ this.out.write(`\x1b[${r};1H\x1b[2K`);
307
378
  }
308
- this.out.write("\x1b[u"); // restore cursor
309
- }
379
+ this._prevFooterTop = this.footerTop;
310
380
 
311
- drawPrompt() {
312
- this.out.write(`\x1b[${this.promptRow};1H\x1b[2K`);
381
+ // Draw top divider rule at footerTop
382
+ this.out.write(`\x1b[${this.footerTop};1H`);
383
+ this.out.write(this.footer[0] || "");
384
+
385
+ // Draw prompt (or busy spinner)
313
386
  if (this._busy) {
387
+ this.out.write(`\x1b[${this.promptRow};1H`);
314
388
  const frame = SPINNER_FRAMES[this._busyFrame % SPINNER_FRAMES.length];
315
389
  this.out.write(C.cyan(frame + " ") + C.dim(this._busyLabel + "…"));
316
- return;
390
+ } else {
391
+ const visual = computeVisualLines(this.buf, this.promptLabel || PROMPT_PREFIX, this.cols);
392
+ const visibleLines = visual.slice(-this.promptLines);
393
+ for (let i = 0; i < visibleLines.length; i++) {
394
+ const row = this.promptRow + i;
395
+ this.out.write(`\x1b[${row};1H`);
396
+ this.out.write(visibleLines[i].text);
397
+ }
317
398
  }
318
- this.out.write((this.promptLabel || PROMPT_PREFIX) + this.buf);
399
+
400
+ // Draw status lines below the prompt
401
+ for (let i = 1; i < FOOTER_LINES; i++) {
402
+ const row = this.statusStartRow + i - 1;
403
+ if (row <= this.rows) {
404
+ this.out.write(`\x1b[${row};1H`);
405
+ this.out.write(this.footer[i] || "");
406
+ }
407
+ }
408
+
409
+ // Place cursor at the end of input
410
+ if (!this._busy) {
411
+ const visual = computeVisualLines(this.buf, this.promptLabel || PROMPT_PREFIX, this.cols);
412
+ const visibleLines = visual.slice(-this.promptLines);
413
+ const lastLine = visibleLines[visibleLines.length - 1] || { visibleLength: 0 };
414
+ const cursorRow = this.promptRow + (visibleLines.length - 1);
415
+ const cursorCol = Math.min(this.cols, lastLine.visibleLength + 1);
416
+ this.out.write(`\x1b[${cursorRow};${cursorCol}H`);
417
+ } else {
418
+ this.out.write("\x1b[u"); // restore cursor
419
+ }
420
+ }
421
+
422
+ drawFooter() {
423
+ this.redraw();
424
+ }
425
+
426
+ drawPrompt() {
427
+ this.redraw();
319
428
  }
320
429
 
321
430
  /** Start the minimal loading state on the prompt row. */
@@ -323,11 +432,11 @@ class Screen {
323
432
  this._busy = true;
324
433
  this._busyLabel = label;
325
434
  this._busyFrame = 0;
326
- this.drawPrompt();
435
+ this.redraw();
327
436
  if (this._busyTimer) clearInterval(this._busyTimer);
328
437
  this._busyTimer = setInterval(() => {
329
438
  this._busyFrame++;
330
- this.drawPrompt();
439
+ this.redraw();
331
440
  }, 90);
332
441
  if (this._busyTimer.unref) this._busyTimer.unref();
333
442
  }
@@ -337,7 +446,7 @@ class Screen {
337
446
  if (this._busyTimer) { clearInterval(this._busyTimer); this._busyTimer = null; }
338
447
  this._busy = false;
339
448
  this._busyResume = false;
340
- this.drawPrompt();
449
+ this.redraw();
341
450
  }
342
451
 
343
452
  /** True while the agent is working (a turn is in progress). */
@@ -349,16 +458,9 @@ class Screen {
349
458
  * Resolve with the next full line the user types. An optional prompt label
350
459
  * replaces the default "you › " (used by the approval gate so the question
351
460
  * 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
461
  */
358
462
  readLine(promptLabel, opts = {}) {
359
463
  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
464
  this._busyResume = this._busy;
363
465
  if (this._busyTimer) { clearInterval(this._busyTimer); this._busyTimer = null; }
364
466
  this._busy = false;
@@ -367,37 +469,50 @@ class Screen {
367
469
  this.resolve = res;
368
470
  this._guardEnterUntil = opts.guardEnter ? Date.now() + 250 : 0;
369
471
  this._echoOnCommit = opts.echo !== false;
370
- this.drawPrompt();
472
+ this.redraw();
371
473
  });
372
474
  }
373
475
 
374
476
  _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();
477
+ this.redraw();
379
478
  }
380
479
 
381
480
  _onData(chunk) {
382
481
  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
482
  if (process.env.ASTRA_KEYDEBUG && this.log) {
387
483
  const bytes = Array.from(chunk).map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ");
388
484
  this.log(`\x1b[35m[keydebug] ${bytes}\x1b[0m`);
389
485
  }
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.
486
+
487
+ // Bracketed paste handling (pasting multi-line blocks into the input)
488
+ let str = s;
489
+ if (str.includes("\x1b[200~") || this._inPaste) {
490
+ if (str.includes("\x1b[200~")) {
491
+ this._inPaste = true;
492
+ str = str.slice(str.indexOf("\x1b[200~") + 6);
493
+ }
494
+ if (str.includes("\x1b[201~")) {
495
+ const end = str.indexOf("\x1b[201~");
496
+ const pasted = str.slice(0, end);
497
+ this.buf += pasted.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
498
+ this._inPaste = false;
499
+ this.redraw();
500
+ return;
501
+ }
502
+ this.buf += str.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
503
+ this.redraw();
504
+ return;
505
+ }
506
+
507
+ // Newline insertion shortcut (Shift+Enter, Option+Enter, Alt+Enter, Ctrl+Enter, Esc+Enter)
508
+ if (isNewlineKey(s, this._pendingEsc)) {
509
+ this._clearEscLatch();
510
+ this.buf += "\n";
511
+ this.redraw();
512
+ return;
513
+ }
514
+
515
+ // Mode toggle: Alt/Option+Tab or Shift+Tab
401
516
  if (this.onToggleMode && (s === "\x1b\t" || s === "\x1b[Z")) {
402
517
  this._clearEscLatch();
403
518
  this.onToggleMode();
@@ -415,17 +530,17 @@ class Screen {
415
530
  this.onCycleReasoning(opt === "up" ? 1 : -1);
416
531
  return;
417
532
  }
418
- return; // recognized an option-arrow we don't act on; swallow it
533
+ return;
419
534
  }
420
535
  }
421
536
 
422
- // A Tab following a latched ESC -> mode toggle (split Alt+Tab delivery).
537
+ // A Tab following a latched ESC -> mode toggle
423
538
  if (this._pendingEsc && s === "\t" && this.onToggleMode) {
424
539
  this._clearEscLatch();
425
540
  this.onToggleMode();
426
541
  return;
427
542
  }
428
- // A latched ESC followed by an arrow tail (e.g. "[C", "b", "f") -> option-arrow.
543
+ // A latched ESC followed by an arrow tail -> option-arrow
429
544
  if (this._pendingEsc) {
430
545
  const opt = matchOptionArrow("\x1b" + s, true);
431
546
  if (opt) {
@@ -440,13 +555,10 @@ class Screen {
440
555
  }
441
556
  return;
442
557
  }
443
- // Any other input right after a latched ESC cancels the latch and is
444
- // processed normally below.
445
558
  this._clearEscLatch();
446
559
  }
447
560
 
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.
561
+ // A lone ESC: latch briefly to disambiguate from split escape sequences
450
562
  if (s === "\x1b") {
451
563
  this._pendingEsc = true;
452
564
  clearTimeout(this._escTimer);
@@ -454,19 +566,15 @@ class Screen {
454
566
  this._pendingEsc = false;
455
567
  if (this.onClearInput) this.onClearInput();
456
568
  this.buf = "";
457
- this.drawPrompt();
569
+ this.redraw();
458
570
  }, 60);
459
571
  return;
460
572
  }
461
573
 
462
- for (const ch of chunk) {
574
+ for (const ch of s) {
463
575
  if (ch === "\r" || ch === "\n") {
464
- // Collapse a CRLF pair into one Enter: ignore a \n right after a \r.
465
576
  if (ch === "\n" && this._lastWasCR) { this._lastWasCR = false; continue; }
466
577
  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
578
  if (
471
579
  this.buf === "" &&
472
580
  this._guardEnterUntil &&
@@ -474,30 +582,25 @@ class Screen {
474
582
  ) {
475
583
  continue;
476
584
  }
477
- const line = this.buf;
585
+ const text = this.buf;
478
586
  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() !== "") {
587
+ if (this._echoOnCommit && text.trim() !== "") {
484
588
  const isUserPrompt = (this.promptLabel || PROMPT_PREFIX) === PROMPT_PREFIX;
485
- this.log((isUserPrompt ? HISTORY_PREFIX : this.promptLabel) + line);
589
+ const prefix = isUserPrompt ? HISTORY_PREFIX : this.promptLabel;
590
+ const lines = text.split("\n");
591
+ for (let i = 0; i < lines.length; i++) {
592
+ this.log((i === 0 ? prefix : " ") + lines[i]);
593
+ }
486
594
  }
487
595
  this.promptLabel = PROMPT_PREFIX;
488
596
  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
597
  if (this._busyResume) { this._busyResume = false; this.startBusy(this._busyLabel); }
492
598
  const r = this.resolve; this.resolve = null;
493
- if (r) r(line);
599
+ if (r) r(text);
494
600
  } else if (ch === "\x7f" || ch === "\b") { // backspace
495
601
  this.buf = this.buf.slice(0, -1);
496
- this.drawPrompt();
602
+ this.redraw();
497
603
  } 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
604
  const r = this.resolve; this.resolve = null;
502
605
  if (r) r("__SIGINT__");
503
606
  if (this.onInterrupt) this.onInterrupt();
@@ -507,7 +610,7 @@ class Screen {
507
610
  } else if (ch >= " ") { // printable
508
611
  this._lastWasCR = false;
509
612
  this.buf += ch;
510
- this.drawPrompt();
613
+ this.redraw();
511
614
  }
512
615
  }
513
616
  }
@@ -524,6 +627,7 @@ Interactive commands:
524
627
  /yolo toggle auto-run of commands (no confirmation)
525
628
 
526
629
  Keyboard shortcuts:
630
+ shift+return insert a newline for multi-line input (alt/opt+enter also works)
527
631
  alt/opt+tab switch agent / bench mode (shift+tab also works)
528
632
  opt+left/right cycle the model
529
633
  opt+up/down cycle reasoning effort (off/low/medium/high)