@aragon-agent/cli 0.6.37 → 0.6.38

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.
@@ -157,6 +157,12 @@ export interface ScrollbackPerf {
157
157
  elidedEntries: number;
158
158
  guardHits: number;
159
159
  guardHitsOutsideResize: number;
160
+ /**
161
+ * Frames taller than the screen, drawn after giving way at their TOP (I-B17). They are
162
+ * no longer swallowed, so `guardHits` stopped being the whole story: a region that
163
+ * outgrew its terminal shows up here instead of there.
164
+ */
165
+ tallFramesTrimmed: number;
160
166
  clippedRows: number;
161
167
  fallbacks: number;
162
168
  resizes: number;
@@ -184,7 +190,7 @@ export interface ScrollbackPerf {
184
190
  anchorTop: number;
185
191
  /** Rows this session pushed into the terminal's own scrollback. */
186
192
  anchorScrollRows: number;
187
- /** Times the geometry was dropped on purpose (column change, shrink, geometry lost). */
193
+ /** Times the geometry was dropped on purpose (column change, nothing left to pin). */
188
194
  anchorResets: number;
189
195
  /** Measurements accepted after the first: the re-probe count, not the resize count. */
190
196
  anchorReprobes: number;
@@ -86,8 +86,8 @@ function formatScrollbackLine() {
86
86
  `diff ${stats.framesDiff}, full ${stats.framesFull}, repaint ${stats.repaints} - ` +
87
87
  `static rows ${stats.staticRowsWritten} - elided ${stats.elidedEntries} - ` +
88
88
  `guardHits ${stats.guardHits} (outside resize ${stats.guardHitsOutsideResize}) - ` +
89
- `clippedRows ${stats.clippedRows} - fallbacks ${stats.fallbacks} - ` +
90
- `resizes ${stats.resizes}`,
89
+ `trimmed ${stats.tallFramesTrimmed} - clippedRows ${stats.clippedRows} - ` +
90
+ `fallbacks ${stats.fallbacks} - resizes ${stats.resizes}`,
91
91
  ];
92
92
  }
93
93
  function mb(bytes) {
@@ -78,8 +78,9 @@ export declare function screenCapacity(rows: number | undefined): number;
78
78
  export declare function clampSpaceBelow(spaceBelow: number, rows: number | undefined): number;
79
79
  /**
80
80
  * A terminal that grew adds rows BELOW the cursor, so the capacity grows by the
81
- * exact difference (D6). Shrinking is NOT handled here: it re-measures instead of
82
- * guessing (D5), so a smaller value would only hide a lost measurement.
81
+ * exact difference (D6). Shrinking is NOT handled here either: the writer keeps the
82
+ * rows the screen still has and re-pins them (I-B17) instead of guessing a smaller
83
+ * value, which would only hide a lost measurement (D5).
83
84
  */
84
85
  export declare function applyResizeGrowth(capacity: number, rowsPrev: number, rowsNow: number): number;
85
86
  /**
@@ -48,8 +48,9 @@ export function clampSpaceBelow(spaceBelow, rows) {
48
48
  }
49
49
  /**
50
50
  * A terminal that grew adds rows BELOW the cursor, so the capacity grows by the
51
- * exact difference (D6). Shrinking is NOT handled here: it re-measures instead of
52
- * guessing (D5), so a smaller value would only hide a lost measurement.
51
+ * exact difference (D6). Shrinking is NOT handled here either: the writer keeps the
52
+ * rows the screen still has and re-pins them (I-B17) instead of guessing a smaller
53
+ * value, which would only hide a lost measurement (D5).
53
54
  */
54
55
  export function applyResizeGrowth(capacity, rowsPrev, rowsNow) {
55
56
  // No capacity to grow: a broken number must not become a canvas by arithmetic.
@@ -54,6 +54,16 @@
54
54
  * - I-B15 · every batch starts by reconciling `top + capacity` with the live
55
55
  * `rows - 1` (a resize event can arrive one frame late), and a column change
56
56
  * resets before it writes.
57
+ * - I-B17 · a SHRINK re-pins the canvas, it never drops it. The canvas keeps
58
+ * `min(canvasRows, screenCapacity(rows))` rows — the screen keeps the rows nearest
59
+ * the parked cursor, so the survivors are its SUFFIX — and its top moves down to
60
+ * `capacity - kept`, which preserves LB-3. Dropping the cache instead made the next
61
+ * batch a SEED, and a seed assumes nothing is below the cursor
62
+ * (`writer-shapes.ts:60-64`); after a shrink that is false, so the batch overflowed
63
+ * the screen and the previous frame — status row included — went into the
64
+ * scrollback: the same row in two places. For the same reason a frame TALLER than
65
+ * the screen gives way at its TOP instead of being swallowed whole: the composer
66
+ * and the status line are the two rows a human is interacting with.
57
67
  * - I-A9 · the writer never writes a probe byte; it only asks the host, which owns
58
68
  * deferral, coalescing and the death switch.
59
69
  */
@@ -102,6 +112,8 @@ export interface ScrollbackWriterStats {
102
112
  guardHits: number;
103
113
  /** The subset NOT explained by a recent resize: the real defect signal. */
104
114
  guardHitsOutsideResize: number;
115
+ /** Frames taller than the screen, drawn after giving way at their TOP (I-B17). */
116
+ tallFramesTrimmed: number;
105
117
  clippedRows: number;
106
118
  elidedEntries: number;
107
119
  resizes: number;
@@ -116,7 +128,7 @@ export interface ScrollbackWriterStats {
116
128
  anchorGapRows: number;
117
129
  /** Rows this session pushed into the scrollback (the "is it scrolling" signal). */
118
130
  anchorScrollRows: number;
119
- /** Times the geometry was dropped on purpose (column change, shrink, gap lost). */
131
+ /** Times the geometry was dropped on purpose (column change, nothing left to pin). */
120
132
  anchorResets: number;
121
133
  /** Measurements accepted AFTER the first one — the re-probe count (not resize events). */
122
134
  anchorReprobes: number;
@@ -54,6 +54,16 @@
54
54
  * - I-B15 · every batch starts by reconciling `top + capacity` with the live
55
55
  * `rows - 1` (a resize event can arrive one frame late), and a column change
56
56
  * resets before it writes.
57
+ * - I-B17 · a SHRINK re-pins the canvas, it never drops it. The canvas keeps
58
+ * `min(canvasRows, screenCapacity(rows))` rows — the screen keeps the rows nearest
59
+ * the parked cursor, so the survivors are its SUFFIX — and its top moves down to
60
+ * `capacity - kept`, which preserves LB-3. Dropping the cache instead made the next
61
+ * batch a SEED, and a seed assumes nothing is below the cursor
62
+ * (`writer-shapes.ts:60-64`); after a shrink that is false, so the batch overflowed
63
+ * the screen and the previous frame — status row included — went into the
64
+ * scrollback: the same row in two places. For the same reason a frame TALLER than
65
+ * the screen gives way at its TOP instead of being swallowed whole: the composer
66
+ * and the status line are the two rows a human is interacting with.
57
67
  * - I-A9 · the writer never writes a probe byte; it only asks the host, which owns
58
68
  * deferral, coalescing and the death switch.
59
69
  */
@@ -110,6 +120,7 @@ export function createScrollbackWriter(o) {
110
120
  fallbacks: 0, guardHits: 0, guardHitsOutsideResize: 0, clippedRows: 0, elidedEntries: 0,
111
121
  resizes: 0, repaints: 0, anchorSource: 'none', anchorCanvasRows: 0, anchorHeldRows: 0,
112
122
  anchorGapRows: 0, anchorScrollRows: 0, anchorResets: 0, anchorReprobes: 0,
123
+ tallFramesTrimmed: 0,
113
124
  };
114
125
  const liveCols = () => {
115
126
  const cols = o.cols();
@@ -205,6 +216,68 @@ export function createScrollbackWriter(o) {
205
216
  stats.bytesWritten += bytes.length;
206
217
  return bytes;
207
218
  };
219
+ /**
220
+ * Drop from the canvas cache the rows a shorter screen no longer has (I-B17).
221
+ *
222
+ * A shrink deletes rows ABOVE the parked cursor, so what survives is the canvas's
223
+ * SUFFIX: the gap goes first, then the oldest content rows. Trimming the cache is
224
+ * what lets the next batch be a DIFF against what is really on screen. Dropping the
225
+ * cache (`reset()`) made it a SEED instead, and a seed assumes nothing is below the
226
+ * cursor (`writer-shapes.ts:60-64`) — after a shrink that is false, so the batch
227
+ * overflowed and the previous frame, status row included, was pushed into the
228
+ * scrollback instead of being erased.
229
+ */
230
+ const trimCanvasCache = (capacity) => {
231
+ const lost = canvasRows - capacity;
232
+ if (lost <= 0)
233
+ return;
234
+ const lostFromGap = Math.min(lost, gapRows);
235
+ gapRows -= lostFromGap;
236
+ held = held.slice(Math.min(lost - lostFromGap, held.length));
237
+ prev = prev === null ? null : prev.slice(lost);
238
+ canvasRows = capacity;
239
+ // A drastic shrink can eat into the region itself (E2). `frameRows` is the height
240
+ // the next diff is aligned against, so it may never claim rows that are gone.
241
+ frameRows = Math.min(frameRows, Math.max(0, capacity - gapRows - held.length));
242
+ };
243
+ /**
244
+ * Re-pin a canvas the terminal just got SHORTER, instead of dropping it (I-B17).
245
+ *
246
+ * `top` moves DOWN (`capacity - kept`) because the rows the screen lost were the ones
247
+ * above the cursor: the parked row is still `rows - 1`, so LB-3 holds for the same
248
+ * reason it always did.
249
+ *
250
+ * `capacity < 1` keeps the old answer (E3): a terminal with no row above the parked one
251
+ * has no canvas left to address, and re-measuring is then the only honest move. Columns
252
+ * are deliberately NOT handled here: a width change re-flows the rows themselves, so
253
+ * the cache describes nothing and still resets (D5 / E4).
254
+ */
255
+ const repinShrunk = (rows) => {
256
+ const room = screenCapacity(rows);
257
+ // Only a MEASURED canvas may be re-pinned. `unknown` means nothing ever established
258
+ // that the cursor sits on the last row, and the arithmetic below starts from exactly
259
+ // that assumption; the append path erases nothing, so it stays the honest answer and
260
+ // keeps its bytes exactly as they were (I-B7).
261
+ const capacity = anchor.kind === 'pinned' ? Math.min(canvasRows, room) : 0;
262
+ if (capacity < 1) {
263
+ reset();
264
+ return;
265
+ }
266
+ trimCanvasCache(capacity);
267
+ anchor = { kind: 'pinned', top: room - capacity, capacity };
268
+ seedDownToTop = 0;
269
+ lastRows = rows;
270
+ // The re-draw this answers is the host's, deferred and coalesced (P0-2 / P0-3):
271
+ // `anchorDirty` is what lets a `repaint()` land while the feed is idle.
272
+ anchorDirty = true;
273
+ publishAnchorStats();
274
+ // Re-measure anyway (I-B14). Keeping the canvas is a bet that the parked row is still
275
+ // `rows - 1`, and only a measurement can confirm the terminal agrees; dropping the
276
+ // probe here would leave a wrong geometry with nothing left to correct it. A drag
277
+ // that fires dozens of resize events still costs one probe: the host coalesces them
278
+ // (P1-3 / E6), and `anchorReprobes` counts measurements, never requests.
279
+ requestProbe();
280
+ };
208
281
  /**
209
282
  * Bring `top + capacity` back in line with the live `rows - 1` BEFORE the batch
210
283
  * reads either of them (§6.11, [P0-2]).
@@ -235,7 +308,10 @@ export function createScrollbackWriter(o) {
235
308
  lastRows = rows;
236
309
  return;
237
310
  }
238
- reset();
311
+ // The `resize` event can arrive a whole frame late, and on a terminal that never
312
+ // answers CPR it never arrives at all: this batch is then the last chance to keep
313
+ // the canvas instead of dropping it (I-B17, W6).
314
+ repinShrunk(rows);
239
315
  };
240
316
  const afterCanvasBatch = (layout, heldNext, frame, raw, cols, rows) => {
241
317
  prev = [...new Array(layout.gap).fill(''), ...heldNext, ...frame];
@@ -402,6 +478,23 @@ export function createScrollbackWriter(o) {
402
478
  canvasRows = lines.length;
403
479
  gapRows = 0;
404
480
  };
481
+ /**
482
+ * Give way at the TOP of a frame taller than the screen (I-B17 / W4).
483
+ *
484
+ * Such a frame cannot be addressed as ONE block, and swallowing it left the PREVIOUS
485
+ * frame on screen — which is how the status row came to be printed twice. The region's
486
+ * last rows are the composer and the status line, the two rows a human is interacting
487
+ * with, so the oldest rows go first and the next (shorter) frame restores the rest.
488
+ *
489
+ * BOTH entry points that draw a frame need this: `repaint()` replays the frame it kept,
490
+ * and that frame can predate a shrink that has since made the screen shorter.
491
+ */
492
+ const giveWayAtTop = (lines, rows) => {
493
+ if (lines.length + 1 <= rows)
494
+ return lines;
495
+ stats.tallFramesTrimmed += 1;
496
+ return lines.slice(lines.length - (rows - 1));
497
+ };
405
498
  const transform = (chunk) => {
406
499
  if (finalized)
407
500
  return '';
@@ -419,12 +512,7 @@ export function createScrollbackWriter(o) {
419
512
  }
420
513
  if (lines === null)
421
514
  return countFallback(!firstChunk);
422
- if (lines.length + 1 > rows) {
423
- // Unreachable by construction (I-S1); never erase on a guess.
424
- reset();
425
- return '';
426
- }
427
- return assemble(lines, rows, liveCols(), 'frame');
515
+ return assemble(giveWayAtTop(lines, rows), rows, liveCols(), 'frame');
428
516
  };
429
517
  const repaint = () => {
430
518
  repaintQueued = false;
@@ -437,7 +525,10 @@ export function createScrollbackWriter(o) {
437
525
  const rows = liveRows();
438
526
  if (rows === undefined || rows < 2)
439
527
  return '';
440
- return assemble(prevRawFrame, rows, liveCols(), 'repaint');
528
+ // The kept frame may predate a shrink, so a replay gets the same top give-way as a
529
+ // live frame (W4): drawing it whole would overflow the new screen and push this
530
+ // session's own rows into the scrollback through the repaint door.
531
+ return assemble(giveWayAtTop(prevRawFrame, rows), rows, liveCols(), 'repaint');
441
532
  };
442
533
  const finalize = (summary) => {
443
534
  if (finalized)
@@ -478,8 +569,10 @@ export function createScrollbackWriter(o) {
478
569
  }
479
570
  if (rowsNow !== undefined && lastRows !== undefined) {
480
571
  if (rowsNow < lastRows) {
481
- // The terminal deleted rows; they are gone and must not be redrawn.
482
- reset();
572
+ // I-B17: the screen kept the rows nearest the cursor, so re-pin the canvas onto
573
+ // them instead of dropping it. A reset made the next batch a seed over a screen
574
+ // that is not empty, which is what left the old status row in the scrollback.
575
+ repinShrunk(rowsNow);
483
576
  return;
484
577
  }
485
578
  if (anchor.kind === 'pinned') {
@@ -503,6 +596,12 @@ export function createScrollbackWriter(o) {
503
596
  return;
504
597
  const rows = liveRows();
505
598
  const capacity = screenCapacity(rows);
599
+ // I-B17: a measurement can arrive after the terminal got shorter without ever
600
+ // announcing it (`rows` is read live, the `resize` event is optional). The cache
601
+ // must then stop describing rows the screen has already dropped, or the reach
602
+ // below would hand the next batch an erase distance longer than the canvas left.
603
+ if (rows !== undefined)
604
+ trimCanvasCache(capacity);
506
605
  // I-B14: the report is "rows BELOW the cursor", and the canvas also still owns
507
606
  // `canvasRows` rows ABOVE it, so the reachable height is their sum. Keeping only
508
607
  // the first term collapses the canvas onto the parked row, and the next batch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.37",
3
+ "version": "0.6.38",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@aragon-agent/core": "^0.2.49",
59
+ "@aragon-agent/core": "^0.2.50",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",