agent-relay 11.2.0 → 11.3.0
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/dist/cli/agent-relay-mcp.d.ts +1 -0
- package/dist/cli/agent-relay-mcp.d.ts.map +1 -1
- package/dist/cli/agent-relay-mcp.js +10 -3
- package/dist/cli/agent-relay-mcp.js.map +1 -1
- package/dist/cli/bootstrap.d.ts +14 -0
- package/dist/cli/bootstrap.d.ts.map +1 -1
- package/dist/cli/bootstrap.js +83 -7
- package/dist/cli/bootstrap.js.map +1 -1
- package/dist/cli/commands/cloud.d.ts.map +1 -1
- package/dist/cli/commands/cloud.js +10 -3
- package/dist/cli/commands/cloud.js.map +1 -1
- package/dist/cli/commands/local-agent.d.ts.map +1 -1
- package/dist/cli/commands/local-agent.js +13 -1
- package/dist/cli/commands/local-agent.js.map +1 -1
- package/dist/cli/commands/setup.d.ts.map +1 -1
- package/dist/cli/commands/setup.js +9 -0
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/lib/attach-drive.d.ts +37 -18
- package/dist/cli/lib/attach-drive.d.ts.map +1 -1
- package/dist/cli/lib/attach-drive.js +178 -75
- package/dist/cli/lib/attach-drive.js.map +1 -1
- package/dist/cli/lib/attach-passthrough.d.ts +5 -0
- package/dist/cli/lib/attach-passthrough.d.ts.map +1 -1
- package/dist/cli/lib/attach-passthrough.js +108 -31
- package/dist/cli/lib/attach-passthrough.js.map +1 -1
- package/dist/cli/lib/attach-view.d.ts +2 -0
- package/dist/cli/lib/attach-view.d.ts.map +1 -1
- package/dist/cli/lib/attach-view.js +10 -0
- package/dist/cli/lib/attach-view.js.map +1 -1
- package/dist/cli/lib/attach.d.ts +127 -11
- package/dist/cli/lib/attach.d.ts.map +1 -1
- package/dist/cli/lib/attach.js +471 -31
- package/dist/cli/lib/attach.js.map +1 -1
- package/dist/cli/lib/broker-lifecycle.d.ts.map +1 -1
- package/dist/cli/lib/broker-lifecycle.js +26 -1
- package/dist/cli/lib/broker-lifecycle.js.map +1 -1
- package/dist/cli/mcp/action-schema.d.ts +2 -2
- package/dist/cli/mcp/action-schema.d.ts.map +1 -1
- package/dist/cli/mcp/action-schema.js +1 -1
- package/dist/cli/mcp/action-schema.js.map +1 -1
- package/dist/cli/mcp/messaging-tools.js +5 -5
- package/dist/cli/mcp/messaging-tools.js.map +1 -1
- package/dist/cli/mcp/tool-results.d.ts +1 -1
- package/dist/cli/mcp/tool-results.d.ts.map +1 -1
- package/dist/cli/mcp/tool-results.js +1 -1
- package/dist/cli/mcp/tool-results.js.map +1 -1
- package/dist/cli/telemetry/client.d.ts +16 -0
- package/dist/cli/telemetry/client.d.ts.map +1 -1
- package/dist/cli/telemetry/client.js +122 -5
- package/dist/cli/telemetry/client.js.map +1 -1
- package/dist/cli/telemetry/config.d.ts +14 -1
- package/dist/cli/telemetry/config.d.ts.map +1 -1
- package/dist/cli/telemetry/config.js +10 -0
- package/dist/cli/telemetry/config.js.map +1 -1
- package/dist/cli/telemetry/events.d.ts +35 -0
- package/dist/cli/telemetry/events.d.ts.map +1 -1
- package/dist/cli/telemetry/index.d.ts +1 -1
- package/dist/cli/telemetry/index.d.ts.map +1 -1
- package/dist/cli/telemetry/index.js +1 -1
- package/dist/cli/telemetry/index.js.map +1 -1
- package/dist/cli/telemetry/posthog-config.d.ts +21 -15
- package/dist/cli/telemetry/posthog-config.d.ts.map +1 -1
- package/dist/cli/telemetry/posthog-config.js +32 -21
- package/dist/cli/telemetry/posthog-config.js.map +1 -1
- package/dist/cli/telemetry/posthog-key.d.ts +18 -0
- package/dist/cli/telemetry/posthog-key.d.ts.map +1 -0
- package/dist/cli/telemetry/posthog-key.js +18 -0
- package/dist/cli/telemetry/posthog-key.js.map +1 -0
- package/dist/index.cjs +3427 -32330
- package/package.json +11 -11
package/dist/cli/lib/attach.js
CHANGED
|
@@ -276,6 +276,195 @@ export function pickInitialTerminalRows(localSize, snapshotRows) {
|
|
|
276
276
|
return snapshotRows;
|
|
277
277
|
return undefined;
|
|
278
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Pick the status-line width. Same precedence as
|
|
281
|
+
* {@link pickInitialTerminalRows} — the LOCAL terminal wins, because the
|
|
282
|
+
* status line has to fit the pane the human is looking at, not the agent's
|
|
283
|
+
* PTY.
|
|
284
|
+
*/
|
|
285
|
+
export function pickInitialTerminalCols(localSize, snapshotCols) {
|
|
286
|
+
if (localSize)
|
|
287
|
+
return localSize.cols;
|
|
288
|
+
if (typeof snapshotCols === 'number' && snapshotCols > 0)
|
|
289
|
+
return snapshotCols;
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
/** Width assumed when the local terminal never reported one. */
|
|
293
|
+
export const DEFAULT_STATUS_LINE_COLS = 80;
|
|
294
|
+
/**
|
|
295
|
+
* Clamp a status-line label to the terminal width.
|
|
296
|
+
*
|
|
297
|
+
* The interactive verbs paint their status line at the bottom row inside an
|
|
298
|
+
* `ESC 7` / `ESC 8` (DECSC/DECRC) pair. That is only safe while the text
|
|
299
|
+
* *fits*: a label wider than the pane wraps past the last row, which scrolls
|
|
300
|
+
* the screen. Because the label is painted on the bottom row, every repaint
|
|
301
|
+
* then scrolls again — promoting the previous status line into the scrollback
|
|
302
|
+
* as content and eating one row of the agent's output each time. A narrow
|
|
303
|
+
* pane therefore turns a single status line into a growing stack of them with
|
|
304
|
+
* the agent's TUI shredded behind it. The `drive` label is 87 columns, so this
|
|
305
|
+
* fired on a standard 80-column terminal too; at 66 columns six repaints cost
|
|
306
|
+
* six rows of agent output.
|
|
307
|
+
*
|
|
308
|
+
* Truncation keeps the paint inside one row, so the wrap — and the scroll
|
|
309
|
+
* cascade it triggers — can never happen. Callers painting a terminal row can
|
|
310
|
+
* reserve the final column as well, avoiding the emulator's wrap-pending state.
|
|
311
|
+
* The tail is the part that carries the key hints, so an over-long label is
|
|
312
|
+
* trimmed from the *middle*: the verb and agent name stay readable and the
|
|
313
|
+
* `Ctrl+…` hints survive.
|
|
314
|
+
*
|
|
315
|
+
* Measured in display columns, not UTF-16 code units: an agent name carrying
|
|
316
|
+
* CJK or emoji is double-width, so a code-unit count would pass a label that
|
|
317
|
+
* still wraps — re-arming the exact cascade this prevents. Slicing is done on
|
|
318
|
+
* whole code points so a surrogate pair is never split in half.
|
|
319
|
+
*/
|
|
320
|
+
export function clampStatusLineText(text, cols, reserveFinalColumn = false) {
|
|
321
|
+
const terminalWidth = typeof cols === 'number' && cols > 0 ? Math.floor(cols) : DEFAULT_STATUS_LINE_COLS;
|
|
322
|
+
const width = reserveFinalColumn ? Math.max(terminalWidth - 1, 0) : terminalWidth;
|
|
323
|
+
if (displayWidth(text) <= width)
|
|
324
|
+
return text;
|
|
325
|
+
// Too narrow to say anything useful — a bare head is still better than a
|
|
326
|
+
// wrap, and `…` alone would be meaningless.
|
|
327
|
+
if (width <= 1)
|
|
328
|
+
return width === 1 ? takeColumns(text, 1).text : '';
|
|
329
|
+
const keep = width - 1; // the ellipsis occupies one column
|
|
330
|
+
const tailBudget = Math.floor(keep / 2);
|
|
331
|
+
const headBudget = keep - tailBudget;
|
|
332
|
+
const head = takeColumns(text, headBudget);
|
|
333
|
+
const tail = tailBudget > 0 ? takeColumns(text, tailBudget, 'end') : { text: '' };
|
|
334
|
+
return `${head.text}…${tail.text}`;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Column width of a string, counting East Asian Wide/Fullwidth characters and
|
|
338
|
+
* emoji as two columns and zero-width joiners/combining marks as none. This is
|
|
339
|
+
* the pragmatic subset a status line needs — it is not a full `wcwidth`.
|
|
340
|
+
*/
|
|
341
|
+
function displayWidth(text) {
|
|
342
|
+
let total = 0;
|
|
343
|
+
for (const char of text)
|
|
344
|
+
total += charWidth(char);
|
|
345
|
+
return total;
|
|
346
|
+
}
|
|
347
|
+
/** Codepoints that occupy no column: combining marks, ZWJ, variation selectors. */
|
|
348
|
+
const ZERO_WIDTH_RANGES = [
|
|
349
|
+
[0x0300, 0x036f],
|
|
350
|
+
[0x200d, 0x200d],
|
|
351
|
+
[0xfe00, 0xfe0f],
|
|
352
|
+
];
|
|
353
|
+
/** East Asian Wide / Fullwidth blocks, plus the emoji planes. */
|
|
354
|
+
const DOUBLE_WIDTH_RANGES = [
|
|
355
|
+
[0x1100, 0x115f],
|
|
356
|
+
[0x2e80, 0xa4cf],
|
|
357
|
+
[0xac00, 0xd7a3],
|
|
358
|
+
[0xf900, 0xfaff],
|
|
359
|
+
[0xfe30, 0xfe6f],
|
|
360
|
+
[0xff00, 0xff60],
|
|
361
|
+
[0xffe0, 0xffe6],
|
|
362
|
+
[0x1f300, 0x1faff],
|
|
363
|
+
[0x20000, 0x3fffd],
|
|
364
|
+
];
|
|
365
|
+
function inRanges(code, ranges) {
|
|
366
|
+
return ranges.some(([lo, hi]) => code >= lo && code <= hi);
|
|
367
|
+
}
|
|
368
|
+
function charWidth(char) {
|
|
369
|
+
const code = char.codePointAt(0) ?? 0;
|
|
370
|
+
if (inRanges(code, ZERO_WIDTH_RANGES))
|
|
371
|
+
return 0;
|
|
372
|
+
return inRanges(code, DOUBLE_WIDTH_RANGES) ? 2 : 1;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Take whole code points from `text` until `budget` columns are used, from the
|
|
376
|
+
* start or the `end`. Never splits a surrogate pair, and never overshoots the
|
|
377
|
+
* budget — a double-width character that would exceed it is dropped.
|
|
378
|
+
*/
|
|
379
|
+
function takeColumns(text, budget, from = 'start') {
|
|
380
|
+
const chars = Array.from(text);
|
|
381
|
+
const ordered = from === 'end' ? chars.slice().reverse() : chars;
|
|
382
|
+
const taken = [];
|
|
383
|
+
let used = 0;
|
|
384
|
+
for (const char of ordered) {
|
|
385
|
+
const w = charWidth(char);
|
|
386
|
+
if (used + w > budget)
|
|
387
|
+
break;
|
|
388
|
+
used += w;
|
|
389
|
+
taken.push(char);
|
|
390
|
+
}
|
|
391
|
+
return { text: (from === 'end' ? taken.reverse() : taken).join('') };
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Reserve the local terminal's final row for an attach status line.
|
|
395
|
+
*
|
|
396
|
+
* Full-screen CLIs treat every PTY row as application-owned. If the remote PTY
|
|
397
|
+
* is resized to the local terminal's full height and Relay then paints a status
|
|
398
|
+
* line over its final row, both renderers continually overwrite each other.
|
|
399
|
+
* The result is the duplicated status text and torn TUI frames seen in drive
|
|
400
|
+
* and passthrough sessions. Give the child one fewer row and column: the row
|
|
401
|
+
* prevents cursor-addressed overlap, while the spare column prevents terminal
|
|
402
|
+
* autowrap from stepping past the child scroll margin into Relay's row.
|
|
403
|
+
*/
|
|
404
|
+
export function reserveStatusLineRow(localSize) {
|
|
405
|
+
if (!localSize)
|
|
406
|
+
return null;
|
|
407
|
+
if (localSize.rows < 3 || localSize.cols < 2)
|
|
408
|
+
return localSize;
|
|
409
|
+
return {
|
|
410
|
+
rows: localSize.rows - 1,
|
|
411
|
+
cols: localSize.cols - 1,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
/** Whether a terminal is large enough to dedicate a non-wrapping status row. */
|
|
415
|
+
export function canReserveStatusLine(localSize) {
|
|
416
|
+
return localSize !== null && localSize.rows >= 3 && localSize.cols >= 2;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Constrain local scrolling to the child PTY's rows while preserving the
|
|
420
|
+
* current cursor. DECSTBM otherwise defaults to the physical terminal height,
|
|
421
|
+
* which includes Relay's status row.
|
|
422
|
+
*/
|
|
423
|
+
export function renderChildScrollRegion(rows) {
|
|
424
|
+
// CSI s/u save only the cursor. DEC ESC 7/8 also preserve terminal state in
|
|
425
|
+
// common emulators and would therefore undo the newly installed margins.
|
|
426
|
+
return `\x1b[s\x1b[1;${Math.max(1, Math.floor(rows))}r\x1b[u`;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Keep a status label strictly inside one terminal row.
|
|
430
|
+
*
|
|
431
|
+
* Writing the final column with autowrap enabled advances to the next row and,
|
|
432
|
+
* on the terminal's bottom row, scrolls the entire TUI. Leave one cell unused
|
|
433
|
+
* and conservatively count non-ASCII code points as two cells so even unusual
|
|
434
|
+
* agent names cannot trigger a wrap.
|
|
435
|
+
*/
|
|
436
|
+
function conservativeTerminalCellWidth(character) {
|
|
437
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
438
|
+
return codePoint >= 0x20 && codePoint <= 0x7e ? 1 : 2;
|
|
439
|
+
}
|
|
440
|
+
export function fitStatusLineText(text, columns) {
|
|
441
|
+
if (columns === undefined || !Number.isFinite(columns))
|
|
442
|
+
return text;
|
|
443
|
+
const available = Math.max(0, Math.floor(columns) - 1);
|
|
444
|
+
if (available === 0)
|
|
445
|
+
return '';
|
|
446
|
+
let width = 0;
|
|
447
|
+
let result = '';
|
|
448
|
+
let truncated = false;
|
|
449
|
+
for (const character of text) {
|
|
450
|
+
const characterWidth = conservativeTerminalCellWidth(character);
|
|
451
|
+
if (width + characterWidth > available) {
|
|
452
|
+
truncated = true;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
result += character;
|
|
456
|
+
width += characterWidth;
|
|
457
|
+
}
|
|
458
|
+
if (!truncated)
|
|
459
|
+
return result;
|
|
460
|
+
while (width + 1 > available && result.length > 0) {
|
|
461
|
+
const characters = Array.from(result);
|
|
462
|
+
const removed = characters.pop() ?? '';
|
|
463
|
+
width -= conservativeTerminalCellWidth(removed);
|
|
464
|
+
result = characters.join('');
|
|
465
|
+
}
|
|
466
|
+
return `${result}~`;
|
|
467
|
+
}
|
|
279
468
|
/**
|
|
280
469
|
* Sync the agent's PTY to the driver's local terminal size. tmux /
|
|
281
470
|
* screen / ssh all do this — without it a TUI in the agent renders into
|
|
@@ -285,20 +474,27 @@ export function pickInitialTerminalRows(localSize, snapshotRows) {
|
|
|
285
474
|
*/
|
|
286
475
|
export async function syncInitialPtySize(connection, name, localSize, verb, deps, options) {
|
|
287
476
|
if (!localSize)
|
|
288
|
-
return;
|
|
477
|
+
return false;
|
|
289
478
|
try {
|
|
290
|
-
await createBrokerClient(connection, deps.fetch).resizePty(name, localSize.rows, localSize.cols, options);
|
|
479
|
+
const result = await createBrokerClient(connection, deps.fetch).resizePty(name, localSize.rows, localSize.cols, options);
|
|
480
|
+
if (result.applied === false) {
|
|
481
|
+
deps.log(`[${verb}] broker did not apply the reserved PTY size; using status repaint fallback`);
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
return true;
|
|
291
485
|
}
|
|
292
486
|
catch (err) {
|
|
293
487
|
const failure = mapBrokerSdkFailure(err);
|
|
294
488
|
deps.log(`[${verb}] could not sync agent PTY size to local terminal (${failure.message ?? 'unknown'}); continuing`);
|
|
489
|
+
return false;
|
|
295
490
|
}
|
|
296
491
|
}
|
|
297
492
|
/**
|
|
298
493
|
* Read the worker's prior inbound delivery mode and flip it to
|
|
299
494
|
* `targetMode`. Returns the previous mode on success so the caller can
|
|
300
495
|
* restore it on detach; returns `null` (and writes an error) when the
|
|
301
|
-
*
|
|
496
|
+
* request fails or the broker reports that the requested transition did not
|
|
497
|
+
* complete, so the caller bails before touching the terminal.
|
|
302
498
|
*
|
|
303
499
|
* Non-404 errors are surfaced as `Error: could not ${actionPhrase}:
|
|
304
500
|
* ${message}` so callers pass verb-appropriate wording (e.g. "switch to
|
|
@@ -316,6 +512,15 @@ export async function switchInboundDeliveryModeOrAbort(connection, name, targetM
|
|
|
316
512
|
}
|
|
317
513
|
try {
|
|
318
514
|
const result = await createBrokerClient(connection, deps.fetch).setInboundDeliveryMode(name, targetMode);
|
|
515
|
+
// A manual_flush → auto_inject transition can fail partway through when
|
|
516
|
+
// draining a parked message into the harness fails. The broker deliberately
|
|
517
|
+
// keeps and reports manual_flush in that case even though the PUT itself
|
|
518
|
+
// succeeded. Starting an attach session as if the target mode landed would
|
|
519
|
+
// render false state and make its first guarded toggle a no-op.
|
|
520
|
+
if (result.mode !== targetMode) {
|
|
521
|
+
deps.error(`Error: could not ${actionPhrase}: broker remained in ${result.mode} mode while draining queued messages`);
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
319
524
|
return { previousMode, sessionRevision: result.revision };
|
|
320
525
|
}
|
|
321
526
|
catch (err) {
|
|
@@ -419,6 +624,12 @@ export class AnsiBoundaryScanner {
|
|
|
419
624
|
case 'ground':
|
|
420
625
|
if (c === 0x1b)
|
|
421
626
|
this.state = 'esc';
|
|
627
|
+
else if (c === 0x9b)
|
|
628
|
+
this.state = 'csi';
|
|
629
|
+
else if (c === 0x9d)
|
|
630
|
+
this.state = 'osc';
|
|
631
|
+
else if (c === 0x90 || c === 0x98 || c === 0x9e || c === 0x9f)
|
|
632
|
+
this.state = 'str';
|
|
422
633
|
return;
|
|
423
634
|
case 'esc':
|
|
424
635
|
if (c === 0x5b)
|
|
@@ -431,30 +642,260 @@ export class AnsiBoundaryScanner {
|
|
|
431
642
|
this.state = 'ground'; // 2-byte escape (ESC 7, ESC 8, ESC c, …)
|
|
432
643
|
return;
|
|
433
644
|
case 'csi':
|
|
434
|
-
if (c ===
|
|
645
|
+
if (c === 0x18 || c === 0x1a)
|
|
646
|
+
this.state = 'ground'; // CAN / SUB cancel the sequence
|
|
647
|
+
else if (c === 0x1b)
|
|
435
648
|
this.state = 'esc'; // stray ESC restarts
|
|
436
649
|
else if (c >= 0x40 && c <= 0x7e)
|
|
437
650
|
this.state = 'ground'; // final byte ends the CSI
|
|
438
651
|
return;
|
|
439
652
|
case 'osc':
|
|
440
|
-
if (c === 0x07)
|
|
653
|
+
if (c === 0x07 || c === 0x9c || c === 0x18 || c === 0x1a)
|
|
441
654
|
this.state = 'ground'; // BEL terminator
|
|
442
655
|
else if (c === 0x1b)
|
|
443
656
|
this.state = 'osc_esc'; // possible ST (ESC \)
|
|
444
657
|
return;
|
|
445
658
|
case 'osc_esc':
|
|
446
|
-
this.state =
|
|
659
|
+
this.state =
|
|
660
|
+
c === 0x5c || c === 0x9c || c === 0x18 || c === 0x1a ? 'ground' : c === 0x1b ? 'osc_esc' : 'osc';
|
|
447
661
|
return;
|
|
448
662
|
case 'str':
|
|
449
|
-
if (c ===
|
|
663
|
+
if (c === 0x9c || c === 0x18 || c === 0x1a)
|
|
664
|
+
this.state = 'ground';
|
|
665
|
+
else if (c === 0x1b)
|
|
450
666
|
this.state = 'str_esc';
|
|
451
667
|
return;
|
|
452
668
|
case 'str_esc':
|
|
453
|
-
this.state =
|
|
669
|
+
this.state =
|
|
670
|
+
c === 0x5c || c === 0x9c || c === 0x18 || c === 0x1a ? 'ground' : c === 0x1b ? 'str_esc' : 'str';
|
|
454
671
|
return;
|
|
455
672
|
}
|
|
456
673
|
}
|
|
457
674
|
}
|
|
675
|
+
/**
|
|
676
|
+
* Detect terminal controls that can erase or replace Relay's reserved status
|
|
677
|
+
* row even though the child PTY itself is one row shorter.
|
|
678
|
+
*
|
|
679
|
+
* Cursor-addressed TUI drawing stays inside the child grid, but erase-display,
|
|
680
|
+
* terminal reset, and alternate-screen switches act on the local terminal as a
|
|
681
|
+
* whole. Attach clients reinstall their child scroll region after those
|
|
682
|
+
* operations; the status controller separately repaints at a safe ANSI
|
|
683
|
+
* boundary. A short carry handles controls split across WS frames.
|
|
684
|
+
*/
|
|
685
|
+
export class StatusLineInvalidationScanner {
|
|
686
|
+
carry = '';
|
|
687
|
+
push(chunk) {
|
|
688
|
+
const input = this.carry + chunk;
|
|
689
|
+
const esc = String.fromCharCode(0x1b);
|
|
690
|
+
const privateModes = new RegExp(`${esc}\\[\\?([0-9;]+)[hl]`, 'g');
|
|
691
|
+
let invalidatesDisplay = false;
|
|
692
|
+
let privateMatch;
|
|
693
|
+
while ((privateMatch = privateModes.exec(input)) !== null) {
|
|
694
|
+
if (privateMatch[1].split(';').some((mode) => ['47', '1047', '1049'].includes(mode))) {
|
|
695
|
+
invalidatesDisplay = true;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
// Retain only an incomplete candidate control. Keeping arbitrary completed
|
|
699
|
+
// output would rediscover the same erase on later chunks and recreate the
|
|
700
|
+
// repaint storm this scanner exists to prevent.
|
|
701
|
+
const lastEscape = input.lastIndexOf(esc);
|
|
702
|
+
const suffix = lastEscape >= 0 ? input.slice(lastEscape) : '';
|
|
703
|
+
const incompleteCsi = suffix.startsWith(`${esc}[`) && /^[?0-9;]*$/.test(suffix.slice(2));
|
|
704
|
+
this.carry = suffix === esc || incompleteCsi ? suffix : '';
|
|
705
|
+
return (
|
|
706
|
+
// ED 0/default clears from the cursor through the reserved final row;
|
|
707
|
+
// ED 2 clears the full display.
|
|
708
|
+
input.includes(`${esc}[J`) ||
|
|
709
|
+
input.includes(`${esc}[0J`) ||
|
|
710
|
+
input.includes(`${esc}[2J`) ||
|
|
711
|
+
// Alternate-screen changes replace the entire visible buffer.
|
|
712
|
+
invalidatesDisplay ||
|
|
713
|
+
// RIS resets the terminal and clears the active display.
|
|
714
|
+
input.includes(`${esc}c`));
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/** Track the child application's active DECSTBM margins across streamed ANSI. */
|
|
718
|
+
export class TerminalScrollRegionTracker {
|
|
719
|
+
parserState = 'ground';
|
|
720
|
+
csiParams = '';
|
|
721
|
+
csiSupported = true;
|
|
722
|
+
stringAllowsBel = false;
|
|
723
|
+
rows;
|
|
724
|
+
top = 1;
|
|
725
|
+
bottom;
|
|
726
|
+
originMode = false;
|
|
727
|
+
alternateActive = false;
|
|
728
|
+
primaryRegion;
|
|
729
|
+
alternateRegion;
|
|
730
|
+
constructor(rows) {
|
|
731
|
+
this.rows = Math.max(1, Math.floor(rows));
|
|
732
|
+
this.bottom = this.rows;
|
|
733
|
+
this.primaryRegion = { top: 1, bottom: this.rows };
|
|
734
|
+
this.alternateRegion = { top: 1, bottom: this.rows };
|
|
735
|
+
}
|
|
736
|
+
setRows(rows) {
|
|
737
|
+
this.rows = Math.max(1, Math.floor(rows));
|
|
738
|
+
this.top = 1;
|
|
739
|
+
this.bottom = this.rows;
|
|
740
|
+
this.primaryRegion = { top: 1, bottom: this.rows };
|
|
741
|
+
this.alternateRegion = { top: 1, bottom: this.rows };
|
|
742
|
+
}
|
|
743
|
+
push(chunk) {
|
|
744
|
+
for (const character of chunk)
|
|
745
|
+
this.consumeCharacter(character);
|
|
746
|
+
}
|
|
747
|
+
get region() {
|
|
748
|
+
return { top: this.top, bottom: this.bottom };
|
|
749
|
+
}
|
|
750
|
+
get isOriginMode() {
|
|
751
|
+
return this.originMode;
|
|
752
|
+
}
|
|
753
|
+
consumeCharacter(character) {
|
|
754
|
+
switch (this.parserState) {
|
|
755
|
+
case 'ground':
|
|
756
|
+
this.consumeGround(character);
|
|
757
|
+
break;
|
|
758
|
+
case 'escape':
|
|
759
|
+
this.consumeEscape(character);
|
|
760
|
+
break;
|
|
761
|
+
case 'csi':
|
|
762
|
+
this.consumeCsi(character);
|
|
763
|
+
break;
|
|
764
|
+
case 'string':
|
|
765
|
+
this.consumeString(character);
|
|
766
|
+
break;
|
|
767
|
+
case 'string_escape':
|
|
768
|
+
this.consumeStringEscape(character);
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
consumeString(character) {
|
|
773
|
+
if (character === '\x1b') {
|
|
774
|
+
this.parserState = 'string_escape';
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (character === '\x9c' ||
|
|
778
|
+
character === '\x18' ||
|
|
779
|
+
character === '\x1a' ||
|
|
780
|
+
(this.stringAllowsBel && character === '\x07')) {
|
|
781
|
+
this.parserState = 'ground';
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
consumeStringEscape(character) {
|
|
785
|
+
if (character === '\\' || character === '\x9c' || character === '\x18' || character === '\x1a') {
|
|
786
|
+
this.parserState = 'ground';
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
this.parserState = character === '\x1b' ? 'string_escape' : 'string';
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
consumeGround(character) {
|
|
793
|
+
if (character === '\x1b')
|
|
794
|
+
this.parserState = 'escape';
|
|
795
|
+
else if (character === '\x9b')
|
|
796
|
+
this.beginCsi();
|
|
797
|
+
else if (['\x90', '\x98', '\x9d', '\x9e', '\x9f'].includes(character)) {
|
|
798
|
+
this.beginString(character === '\x9d');
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
consumeEscape(character) {
|
|
802
|
+
if (character === '[')
|
|
803
|
+
this.beginCsi();
|
|
804
|
+
else if (['P', 'X', ']', '^', '_'].includes(character))
|
|
805
|
+
this.beginString(character === ']');
|
|
806
|
+
else {
|
|
807
|
+
if (character === 'c')
|
|
808
|
+
this.resetTrackedState();
|
|
809
|
+
this.parserState = character === '\x1b' ? 'escape' : 'ground';
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
beginCsi() {
|
|
813
|
+
this.parserState = 'csi';
|
|
814
|
+
this.csiParams = '';
|
|
815
|
+
this.csiSupported = true;
|
|
816
|
+
}
|
|
817
|
+
beginString(allowsBel) {
|
|
818
|
+
this.parserState = 'string';
|
|
819
|
+
this.stringAllowsBel = allowsBel;
|
|
820
|
+
}
|
|
821
|
+
consumeCsi(character) {
|
|
822
|
+
if (character === '\x18' || character === '\x1a') {
|
|
823
|
+
this.parserState = 'ground';
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (character === '\x1b') {
|
|
827
|
+
this.parserState = 'escape';
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const code = character.codePointAt(0) ?? 0;
|
|
831
|
+
if (code >= 0x30 && code <= 0x3f) {
|
|
832
|
+
this.csiParams += character;
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (code >= 0x20 && code <= 0x2f) {
|
|
836
|
+
this.csiSupported = false;
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
840
|
+
if (this.csiSupported)
|
|
841
|
+
this.applyCsi(this.csiParams, character);
|
|
842
|
+
this.parserState = 'ground';
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
applyCsi(params, final) {
|
|
846
|
+
if ((final === 'h' || final === 'l') && params.startsWith('?')) {
|
|
847
|
+
this.applyPrivateModes(params.slice(1).split(';'), final === 'h');
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (final !== 'r' || params.startsWith('?'))
|
|
851
|
+
return;
|
|
852
|
+
const [topRaw = '', bottomRaw = ''] = params.split(';');
|
|
853
|
+
const parsedTop = topRaw === '' ? 0 : Number.parseInt(topRaw, 10);
|
|
854
|
+
const parsedBottom = bottomRaw === '' ? 0 : Number.parseInt(bottomRaw, 10);
|
|
855
|
+
const top = parsedTop === 0 ? 1 : parsedTop;
|
|
856
|
+
const bottom = parsedBottom === 0 ? this.rows : parsedBottom;
|
|
857
|
+
if (Number.isFinite(top) && Number.isFinite(bottom) && top >= 1 && bottom > top) {
|
|
858
|
+
const clampedTop = Math.min(top, this.rows);
|
|
859
|
+
const clampedBottom = Math.min(bottom, this.rows);
|
|
860
|
+
if (clampedBottom > clampedTop) {
|
|
861
|
+
this.top = clampedTop;
|
|
862
|
+
this.bottom = clampedBottom;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
applyPrivateModes(modes, enabled) {
|
|
867
|
+
if (modes.includes('6'))
|
|
868
|
+
this.originMode = enabled;
|
|
869
|
+
if (modes.some((mode) => ['47', '1047', '1049'].includes(mode))) {
|
|
870
|
+
this.switchScreenBuffer(enabled, enabled);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
resetTrackedState() {
|
|
874
|
+
this.top = 1;
|
|
875
|
+
this.bottom = this.rows;
|
|
876
|
+
this.originMode = false;
|
|
877
|
+
this.alternateActive = false;
|
|
878
|
+
this.primaryRegion = { top: 1, bottom: this.rows };
|
|
879
|
+
this.alternateRegion = { top: 1, bottom: this.rows };
|
|
880
|
+
}
|
|
881
|
+
switchScreenBuffer(enterAlternate, resetAlternate) {
|
|
882
|
+
if (enterAlternate === this.alternateActive)
|
|
883
|
+
return;
|
|
884
|
+
if (resetAlternate) {
|
|
885
|
+
this.alternateRegion = { top: 1, bottom: this.rows };
|
|
886
|
+
}
|
|
887
|
+
if (this.alternateActive) {
|
|
888
|
+
this.alternateRegion = { top: this.top, bottom: this.bottom };
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
this.primaryRegion = { top: this.top, bottom: this.bottom };
|
|
892
|
+
}
|
|
893
|
+
this.alternateActive = enterAlternate;
|
|
894
|
+
const region = enterAlternate ? this.alternateRegion : this.primaryRegion;
|
|
895
|
+
this.top = region.top;
|
|
896
|
+
this.bottom = region.bottom;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
458
899
|
/**
|
|
459
900
|
* Coordinates status-line repaints for the interactive attach clients.
|
|
460
901
|
*
|
|
@@ -462,8 +903,8 @@ export class AnsiBoundaryScanner {
|
|
|
462
903
|
* - **Non-TTY skip** — when `enabled` is false, nothing is ever written.
|
|
463
904
|
* - **Boundary-hold** — a repaint is deferred while the observed output ends
|
|
464
905
|
* mid ANSI escape sequence, so the status line never splices into a
|
|
465
|
-
* half-transmitted CSI. It paints
|
|
466
|
-
*
|
|
906
|
+
* half-transmitted CSI. It paints only after a later chunk returns the
|
|
907
|
+
* stream to a real boundary.
|
|
467
908
|
* - **Coalescing** — repaints are rate-limited to at most one per
|
|
468
909
|
* `coalesceMs`, shrinking the window in which our save/restore-cursor wrap
|
|
469
910
|
* can clobber the agent's own pending DECSC.
|
|
@@ -478,25 +919,32 @@ export class StatusLineController {
|
|
|
478
919
|
opts;
|
|
479
920
|
scanner = new AnsiBoundaryScanner();
|
|
480
921
|
timer = null;
|
|
481
|
-
timerForce = false;
|
|
482
922
|
wantPaint = false;
|
|
483
923
|
lastPaintAt = Number.NEGATIVE_INFINITY;
|
|
484
924
|
disposed = false;
|
|
485
925
|
constructor(opts) {
|
|
486
926
|
this.opts = opts;
|
|
487
927
|
}
|
|
928
|
+
isEnabled() {
|
|
929
|
+
return typeof this.opts.enabled === 'function' ? this.opts.enabled() : this.opts.enabled;
|
|
930
|
+
}
|
|
488
931
|
/** Record server output for boundary tracking; flush a held repaint if the
|
|
489
932
|
* stream is now back at a sequence boundary. */
|
|
490
933
|
observeOutput(chunk) {
|
|
491
934
|
if (this.disposed)
|
|
492
935
|
return;
|
|
493
936
|
this.scanner.push(chunk);
|
|
937
|
+
if (!this.isEnabled()) {
|
|
938
|
+
this.wantPaint = false;
|
|
939
|
+
this.clearTimer();
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
494
942
|
if (this.wantPaint)
|
|
495
943
|
this.flush();
|
|
496
944
|
}
|
|
497
945
|
/** Request a repaint (coalesced + boundary-held). */
|
|
498
946
|
request() {
|
|
499
|
-
if (this.disposed || !this.
|
|
947
|
+
if (this.disposed || !this.isEnabled())
|
|
500
948
|
return;
|
|
501
949
|
this.wantPaint = true;
|
|
502
950
|
this.flush();
|
|
@@ -511,39 +959,32 @@ export class StatusLineController {
|
|
|
511
959
|
return (this.opts.now ?? Date.now)();
|
|
512
960
|
}
|
|
513
961
|
flush() {
|
|
514
|
-
if (this.disposed || !this.wantPaint)
|
|
962
|
+
if (this.disposed || !this.wantPaint || !this.isEnabled())
|
|
515
963
|
return;
|
|
516
964
|
if (!this.scanner.atBoundary) {
|
|
517
|
-
this.arm(this.opts.boundaryHoldMs ?? 100, true);
|
|
518
965
|
return;
|
|
519
966
|
}
|
|
520
|
-
// Back at a boundary: any pending *force* (boundary-hold) timer carries the
|
|
521
|
-
// wrong deadline now — it was armed to paint mid-sequence after
|
|
522
|
-
// `boundaryHoldMs`, which would either paint later than the coalescing
|
|
523
|
-
// window needs or bypass the window entirely. Clear it so the normal
|
|
524
|
-
// coalescing logic below re-arms a plain timer for the correct remainder.
|
|
525
|
-
// A non-force (coalescing) timer already has the right deadline, so leave
|
|
526
|
-
// it in place.
|
|
527
|
-
if (this.timer && this.timerForce)
|
|
528
|
-
this.clearTimer();
|
|
529
967
|
const elapsed = this.now() - this.lastPaintAt;
|
|
530
968
|
if (elapsed >= this.opts.coalesceMs) {
|
|
531
969
|
this.paintNow();
|
|
532
970
|
}
|
|
533
971
|
else {
|
|
534
|
-
this.arm(this.opts.coalesceMs - elapsed
|
|
972
|
+
this.arm(this.opts.coalesceMs - elapsed);
|
|
535
973
|
}
|
|
536
974
|
}
|
|
537
975
|
paintNow() {
|
|
538
976
|
this.clearTimer();
|
|
977
|
+
if (!this.isEnabled()) {
|
|
978
|
+
this.wantPaint = false;
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
539
981
|
this.wantPaint = false;
|
|
540
982
|
this.lastPaintAt = this.now();
|
|
541
983
|
this.opts.write(this.opts.render());
|
|
542
984
|
}
|
|
543
|
-
arm(ms
|
|
985
|
+
arm(ms) {
|
|
544
986
|
if (this.timer)
|
|
545
987
|
return;
|
|
546
|
-
this.timerForce = force;
|
|
547
988
|
const set = this.opts.setTimer ??
|
|
548
989
|
((fn, delay) => {
|
|
549
990
|
const t = setTimeout(fn, delay);
|
|
@@ -552,12 +993,11 @@ export class StatusLineController {
|
|
|
552
993
|
});
|
|
553
994
|
this.timer = set(() => {
|
|
554
995
|
this.timer = null;
|
|
555
|
-
if (this.disposed || !this.wantPaint)
|
|
996
|
+
if (this.disposed || !this.wantPaint || !this.isEnabled()) {
|
|
997
|
+
this.wantPaint = false;
|
|
556
998
|
return;
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
else
|
|
560
|
-
this.flush();
|
|
999
|
+
}
|
|
1000
|
+
this.flush();
|
|
561
1001
|
}, ms);
|
|
562
1002
|
}
|
|
563
1003
|
clearTimer() {
|