@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 +2 -0
- package/package.json +1 -1
- package/src/model.js +178 -11
- package/src/repl.js +191 -87
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
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 =
|
|
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
|
|
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
|
-
/**
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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.
|
|
227
|
-
* the
|
|
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
|
|
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
|
|
250
|
-
get
|
|
251
|
-
|
|
252
|
-
get
|
|
253
|
-
|
|
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.
|
|
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.
|
|
293
|
-
this.drawPrompt();
|
|
362
|
+
this.redraw();
|
|
294
363
|
}
|
|
295
364
|
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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.
|
|
309
|
-
}
|
|
379
|
+
this._prevFooterTop = this.footerTop;
|
|
310
380
|
|
|
311
|
-
|
|
312
|
-
this.out.write(`\x1b[${this.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
435
|
+
this.redraw();
|
|
327
436
|
if (this._busyTimer) clearInterval(this._busyTimer);
|
|
328
437
|
this._busyTimer = setInterval(() => {
|
|
329
438
|
this._busyFrame++;
|
|
330
|
-
this.
|
|
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.
|
|
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.
|
|
472
|
+
this.redraw();
|
|
371
473
|
});
|
|
372
474
|
}
|
|
373
475
|
|
|
374
476
|
_onResize() {
|
|
375
|
-
this.
|
|
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
|
-
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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;
|
|
533
|
+
return;
|
|
419
534
|
}
|
|
420
535
|
}
|
|
421
536
|
|
|
422
|
-
// A Tab following a latched ESC -> mode toggle
|
|
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
|
|
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.
|
|
569
|
+
this.redraw();
|
|
458
570
|
}, 60);
|
|
459
571
|
return;
|
|
460
572
|
}
|
|
461
573
|
|
|
462
|
-
for (const ch of
|
|
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
|
|
585
|
+
const text = this.buf;
|
|
478
586
|
this.buf = "";
|
|
479
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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)
|