@aragon-agent/cli 0.6.31 → 0.6.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +2970 -2826
  3. package/dist/agent/controller.d.ts +46 -0
  4. package/dist/agent/controller.js +175 -7
  5. package/dist/agent/job-notices.d.ts +143 -0
  6. package/dist/agent/job-notices.js +182 -0
  7. package/dist/agent/reducer.d.ts +21 -1
  8. package/dist/agent/reducer.js +7 -0
  9. package/dist/agent/system-prompt.d.ts +12 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +236 -8
  12. package/dist/commands/builtins.js +23 -8
  13. package/dist/commands/perf.d.ts +77 -1
  14. package/dist/commands/perf.js +37 -5
  15. package/dist/config/cli-commands.d.ts +27 -0
  16. package/dist/config/cli-commands.js +54 -1
  17. package/dist/config/env.js +38 -0
  18. package/dist/config/load.js +50 -1
  19. package/dist/config/schema.d.ts +73 -0
  20. package/dist/config/schema.js +34 -0
  21. package/dist/config/store.js +14 -1
  22. package/dist/exec/events.d.ts +29 -3
  23. package/dist/exec/events.js +3 -12
  24. package/dist/exec/runner.d.ts +24 -0
  25. package/dist/exec/runner.js +79 -0
  26. package/dist/input/cursor-report.d.ts +61 -0
  27. package/dist/input/cursor-report.js +97 -0
  28. package/dist/input/stdin-filter.d.ts +22 -0
  29. package/dist/input/stdin-filter.js +48 -3
  30. package/dist/proc/limits.d.ts +76 -2
  31. package/dist/proc/limits.js +83 -2
  32. package/dist/proc/log-mirror.d.ts +59 -0
  33. package/dist/proc/log-mirror.js +203 -0
  34. package/dist/proc/log-ring.d.ts +22 -0
  35. package/dist/proc/log-ring.js +20 -1
  36. package/dist/proc/notices.d.ts +57 -0
  37. package/dist/proc/notices.js +101 -0
  38. package/dist/proc/progress.d.ts +40 -0
  39. package/dist/proc/progress.js +135 -0
  40. package/dist/proc/prompt.d.ts +24 -2
  41. package/dist/proc/prompt.js +51 -2
  42. package/dist/proc/supervisor.d.ts +35 -2
  43. package/dist/proc/supervisor.js +142 -43
  44. package/dist/proc/types.d.ts +89 -1
  45. package/dist/proc/wait.d.ts +57 -0
  46. package/dist/proc/wait.js +133 -0
  47. package/dist/session/persist.js +3 -1
  48. package/dist/session/validate-session.js +17 -0
  49. package/dist/tools/bash-tool.d.ts +12 -1
  50. package/dist/tools/bash-tool.js +100 -13
  51. package/dist/tools/index.d.ts +26 -2
  52. package/dist/tools/index.js +43 -2
  53. package/dist/tools/job-tools.d.ts +54 -0
  54. package/dist/tools/job-tools.js +329 -0
  55. package/dist/tools/proc-tools.d.ts +1 -1
  56. package/dist/tools/proc-tools.js +49 -17
  57. package/dist/ui/ActivityLine.js +7 -0
  58. package/dist/ui/App.js +98 -12
  59. package/dist/ui/ScrollbackShell.d.ts +62 -11
  60. package/dist/ui/ScrollbackShell.js +11 -3
  61. package/dist/ui/StatusBar.d.ts +5 -0
  62. package/dist/ui/StatusBar.js +1 -0
  63. package/dist/ui/Transcript.d.ts +14 -0
  64. package/dist/ui/Transcript.js +14 -2
  65. package/dist/ui/entries/ServiceCard.d.ts +17 -1
  66. package/dist/ui/entries/ServiceCard.js +39 -4
  67. package/dist/ui/layout/status-layout.d.ts +13 -1
  68. package/dist/ui/layout/status-layout.js +10 -0
  69. package/dist/ui/layout/virtual-window.js +14 -1
  70. package/dist/ui/run-status-row.d.ts +12 -0
  71. package/dist/ui/screen.d.ts +17 -4
  72. package/dist/ui/screen.js +14 -6
  73. package/dist/ui/scrollback/LiveTail.d.ts +79 -0
  74. package/dist/ui/scrollback/LiveTail.js +32 -0
  75. package/dist/ui/scrollback/band.d.ts +114 -0
  76. package/dist/ui/scrollback/band.js +130 -0
  77. package/dist/ui/scrollback/budget.d.ts +51 -10
  78. package/dist/ui/scrollback/budget.js +69 -20
  79. package/dist/ui/scrollback/canvas.d.ts +99 -0
  80. package/dist/ui/scrollback/canvas.js +113 -0
  81. package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
  82. package/dist/ui/scrollback/cursor-probe.js +133 -0
  83. package/dist/ui/scrollback/limits.d.ts +61 -4
  84. package/dist/ui/scrollback/limits.js +61 -4
  85. package/dist/ui/scrollback/live-preview.d.ts +18 -10
  86. package/dist/ui/scrollback/live-preview.js +14 -11
  87. package/dist/ui/scrollback/scrollback-writer.d.ts +104 -16
  88. package/dist/ui/scrollback/scrollback-writer.js +368 -82
  89. package/dist/ui/scrollback/tail-window.d.ts +60 -0
  90. package/dist/ui/scrollback/tail-window.js +73 -0
  91. package/dist/ui/scrollback/use-scrollback-view.d.ts +34 -15
  92. package/dist/ui/scrollback/use-scrollback-view.js +110 -59
  93. package/dist/ui/scrollback/writer-shapes.d.ts +68 -2
  94. package/dist/ui/scrollback/writer-shapes.js +53 -8
  95. package/package.json +3 -3
@@ -1,60 +1,115 @@
1
1
  /**
2
2
  * ScrollbackWriter — the byte contract of native-scroll mode
3
- * (tui-native-terminal-scroll §4.3).
3
+ * (tui-native-terminal-scroll §4.3, extended by tui-bottom-anchored-live-region
4
+ * and re-based on a CANVAS by tui-canvas-bottom-anchor).
4
5
  *
5
6
  * It implements `FrameDiffer`, so `stdout-frame-writer.ts::wrapStdoutForFrames`
6
7
  * binds it to a real stream exactly as it binds the full-screen differ. What it
7
8
  * does differently: the screen is NOT the app's. Settled conversation rows are
8
9
  * printed into the host terminal's own scrollback, and Ink's frame is only a small
9
- * LIVE REGION parked directly under the last printed row.
10
+ * LIVE REGION parked under the last printed row.
10
11
  *
11
- * GEOMETRY IS RELATIVE (D2). A batch is `CUU(H0)` to the top of the previous
12
- * region, erase down, print static rows, print the new region, park one row below
13
- * it. Nothing in that sequence depends on the terminal's absolute row count, so a
14
- * terminal that scrolls underneath it cannot misplace anything.
12
+ * GEOMETRY IS RELATIVE (D2/D4). A batch is `CUU(canvasRows)` to the top of the
13
+ * previous CANVAS, erase down, skip the gap, print the content rows, print the
14
+ * region, park on the last row. Nothing needs the terminal's absolute row count, so
15
+ * a terminal that scrolls underneath cannot misplace anything.
16
+ *
17
+ * THE CANVAS (tui-canvas-bottom-anchor D1/D3). The `rows - 1` rows above the parked
18
+ * row belong to the writer and are always laid out top-down as
19
+ * `[gap][content][region]`. The previous feature put its blank rows between the
20
+ * content and the region (`[content][pad][region]`), which is why a short
21
+ * conversation left a reply floating far above the composer. The blank space now
22
+ * lives above every visible content row, so the newest printed row always touches
23
+ * the top row of the live region, while the composer stays on the last two rows.
24
+ * The gap is SKIPPED with CUD and never written, so a blank row can never be pushed
25
+ * into the scrollback (I-B3).
26
+ *
27
+ * THE PRICE, and it is the only one: the content rows still on the canvas can be
28
+ * re-drawn when the layout changes (a region collapse, a longer draft), because the
29
+ * rows that the region gives back have to move onto the canvas top. That is why the
30
+ * writer keeps a bounded cache of them (`held`, at most `capacity - H` rows). A row
31
+ * that leaves the canvas — pushed into the scrollback or erased at exit — is never
32
+ * written again, so the history the user scrolls through is exactly what was
33
+ * printed, once (I-B5). `anchor: unknown` (nothing measured: no TTY, no filter, no
34
+ * CPR answer) keeps the pre-feature bytes exactly, which is what keeps the old byte
35
+ * tests meaningful (I-B7).
15
36
  *
16
37
  * THE INVARIANTS THAT FAIL SILENTLY IF DROPPED:
17
- * - I-S4 · every batch parks the cursor at (regionBottom + 1, column 0), the same
18
- * place Ink's `previousLineCount = H + 1` expects, so falling back to a raw Ink
19
- * write is still correct.
38
+ * - I-S4 · every batch parks the cursor one row below the region, column 0, the
39
+ * same place Ink's `previousLineCount = H + 1` expects.
20
40
  * - I-S8 · geometry in doubt (foreign write, unparseable chunk) => `prev = null`
21
- * => next batch is a SEED. Never keep erasing on a stale `H0`.
22
- * - I-S14 · after `finalize` EVERY Ink write is swallowed. Ink's unmount renders
23
- * once more (and a 32 ms trailing throttle call can follow); passed through,
24
- * its `eraseLines(H + 1)` would erase the summary and the end of the
25
- * conversation. With an unknown or sub-2-row terminal the frame is still
26
- * parsed so `prev`/`H0` track Ink's own `previousLineCount`.
41
+ * => next batch is a SEED. Never keep erasing on a stale height.
42
+ * - I-S14 · after `finalize` EVERY Ink write is swallowed.
27
43
  * - I-S13 · no emitted row is wider than `cols - 1` (clipped, counted).
44
+ * - I-B1 · a batch ends parked on `rows - 1`, which is what the next `CUU` assumes.
45
+ * - I-B3 · blank rows only ever exist as SKIPPED rows, and a batch that scrolls has
46
+ * `gap = 0`, so the scrollback never receives an empty row from this file.
47
+ * - I-B5 · `held` is a contiguous suffix of the printed content, and `dropped` is
48
+ * exactly the number of rows this batch pushed off the top. The BYTES carry the
49
+ * untrimmed content: the terminal can only scroll away rows it was given.
50
+ * - I-B14 · a re-measurement re-derives `capacity` from `spaceBelow + canvasRows`,
51
+ * never from `spaceBelow` alone: the canvas itself still owns the rows above the
52
+ * cursor, and forgetting them makes the next batch read its own screen as an
53
+ * overflow and print every visible row a second time.
54
+ * - I-B15 · every batch starts by reconciling `top + capacity` with the live
55
+ * `rows - 1` (a resize event can arrive one frame late), and a column change
56
+ * resets before it writes.
57
+ * - I-A9 · the writer never writes a probe byte; it only asks the host, which owns
58
+ * deferral, coalescing and the death switch.
28
59
  */
29
60
  import { parseInkFrame } from '../frame-parser.js';
61
+ import { clampSpaceBelow, resolveCanvasLayout, screenCapacity, UNKNOWN_CANVAS, applyResizeGrowth, } from './canvas.js';
30
62
  import { SCROLLBACK_LIMITS } from './limits.js';
31
- import { clipLine, diffBytes, estimateReflowRows, finalizeBytes, fullBytes, seedBytes, } from './writer-shapes.js';
63
+ import { canvasBytes, canvasSeedBytes, clipLine, diffBytes, finalizeBytes, fullBytes, seedBytes, } from './writer-shapes.js';
32
64
  /** `ansiEscapes.clearTerminal` — Ink's tall-frame write. It contains `ESC[3J`. */
33
65
  const CLEAR_TERMINAL = '\x1b[2J\x1b[3J\x1b[H';
34
66
  export function createScrollbackWriter(o) {
35
67
  const now = o.now ?? Date.now;
36
68
  const maxBatch = SCROLLBACK_LIMITS.maxRowsPerBatch;
37
- /** Region lines as written (clipped), or null when the geometry is in doubt. */
69
+ /** Where the bottom of the screen is, as far as anything has measured it. */
70
+ let anchor = UNKNOWN_CANVAS;
71
+ /** Content rows still on the canvas, oldest first, already clipped (I-B9). */
72
+ let held = [];
73
+ /** Gap rows of the previous batch. */
74
+ let gapRows = 0;
75
+ /** Frame rows of the previous batch (`regionRows()`). */
76
+ let frameRows = 0;
77
+ /** Canvas height of the previous batch = the erase distance. */
78
+ let canvasRows = 0;
79
+ /** The canvas as it stands on screen (`''` = a skipped gap row), or null if lost. */
38
80
  let prev = null;
39
- /** The same lines before clipping, so unchanged rows are not re-measured. */
40
- let prevRaw = null;
41
- /** Rows between region top and the parked cursor. Tracks Ink's own count. */
42
- let regionHeight = 0;
43
- /** Whether anything has ever been written (a re-seed then starts on a new line). */
81
+ /** The last frame BEFORE clipping, so unchanged rows are not re-measured. */
82
+ let prevRawFrame = null;
83
+ /** The last frame AFTER clipping, aligned with `prevRawFrame` (P2-3). */
84
+ let prevClippedFrame = null;
44
85
  let everWritten = false;
45
86
  let seenAnyChunk = false;
46
87
  let finalized = false;
47
88
  let guardTripped = false;
48
89
  let consecutiveGuard = 0;
49
90
  let lastResizeAt = Number.NEGATIVE_INFINITY;
50
- let resizePending = false;
51
- /** Width the previous region was written at, for the reflow estimate. */
91
+ /** Width the previous canvas was written at, for the column-change reset. */
52
92
  let lastWriteCols;
93
+ /** Terminal height at the previous batch: the basis of a resize delta. */
94
+ let lastRows;
53
95
  let repaintQueued = false;
96
+ let anchorSource = 'none';
97
+ /** A fresh measurement is waiting to be consumed by the next write. */
98
+ let anchorDirty = false;
99
+ let anchorReprobes = 0;
100
+ let scrolledRows = 0;
101
+ /**
102
+ * Rows between the cursor and the canvas top for the NEXT seed, when the canvas is
103
+ * shorter than the measured space below the cursor (a terminal taller than
104
+ * `maxCanvasRows`). Zero in every other state, because a canvas that exists has its
105
+ * top above the parked cursor by construction.
106
+ */
107
+ let seedDownToTop = 0;
54
108
  const stats = {
55
109
  framesSeed: 0, framesDiff: 0, framesFull: 0, staticRowsWritten: 0, bytesWritten: 0,
56
110
  fallbacks: 0, guardHits: 0, guardHitsOutsideResize: 0, clippedRows: 0, elidedEntries: 0,
57
- resizes: 0, repaints: 0,
111
+ resizes: 0, repaints: 0, anchorSource: 'none', anchorCanvasRows: 0, anchorHeldRows: 0,
112
+ anchorGapRows: 0, anchorScrollRows: 0, anchorResets: 0, anchorReprobes: 0,
58
113
  };
59
114
  const liveCols = () => {
60
115
  const cols = o.cols();
@@ -71,19 +126,56 @@ export function createScrollbackWriter(o) {
71
126
  // Deferred by the host (setImmediate): this can fire from inside Ink's write.
72
127
  o.requestRepaint();
73
128
  };
74
- const invalidate = () => {
129
+ /**
130
+ * Ask for a measurement. Never writes anything itself, and never after `finalize`
131
+ * (I-A9): the host owns the deferral, the coalescing and the death switch.
132
+ */
133
+ const requestProbe = () => {
134
+ if (finalized || anchorSource === 'none')
135
+ return;
136
+ o.requestAnchorProbe?.();
137
+ };
138
+ /** Report what the canvas looks like right now. Produces no bytes. */
139
+ const publishAnchorStats = () => {
140
+ const pinned = anchor.kind === 'pinned';
141
+ stats.anchorSource = anchorSource;
142
+ stats.anchorCanvasRows = pinned ? canvasRows : 0;
143
+ stats.anchorHeldRows = pinned ? held.length : 0;
144
+ stats.anchorGapRows = pinned ? gapRows : 0;
145
+ stats.anchorScrollRows = scrolledRows;
146
+ stats.anchorReprobes = anchorReprobes;
147
+ };
148
+ /**
149
+ * Drop the canvas and go back to the append path (D5, I-B8, I-A3).
150
+ *
151
+ * Shared by a column change, a shrink, `invalidate()` and a lost geometry: all
152
+ * four have the same safe answer, "re-measure and print nothing until then".
153
+ * The rows already on screen are NOT redrawn — they are not where the cache says
154
+ * they are, and a wrong erase both duplicates and destroys user-visible content.
155
+ */
156
+ const reset = () => {
75
157
  prev = null;
76
- prevRaw = null;
77
- resizePending = false;
158
+ prevRawFrame = null;
159
+ prevClippedFrame = null;
160
+ held = [];
161
+ gapRows = 0;
162
+ canvasRows = 0;
163
+ seedDownToTop = 0;
164
+ anchor = UNKNOWN_CANVAS;
165
+ anchorDirty = false;
166
+ stats.anchorResets += 1;
167
+ publishAnchorStats();
168
+ requestProbe();
78
169
  };
79
- /** Clip every row to `cols - 1`, reusing the previous result for unchanged rows (I-S13). */
80
- const clipRegion = (lines, cols) => {
170
+ /** Clip a frame row to `cols - 1`, reusing the previous result for unchanged rows. */
171
+ const clipFrame = (raw, cols) => {
81
172
  if (cols === undefined)
82
- return [...lines];
173
+ return [...raw];
83
174
  const limit = cols - 1;
84
- return lines.map((line, i) => {
85
- if (prevRaw && prev && prevRaw[i] === line && prev[i] !== undefined)
86
- return prev[i];
175
+ return raw.map((line, i) => {
176
+ const cached = prevClippedFrame?.[i];
177
+ if (cached !== undefined && prevRawFrame?.[i] === line)
178
+ return cached;
87
179
  const result = clipLine(line, limit);
88
180
  if (result.clipped)
89
181
  stats.clippedRows += 1;
@@ -100,13 +192,6 @@ export function createScrollbackWriter(o) {
100
192
  return result.text;
101
193
  });
102
194
  };
103
- /** Rows to move up to reach the region top: the plain height, or a post-resize estimate. */
104
- const eraseDistance = (rows, cols) => {
105
- if (!resizePending || prev === null || cols === undefined)
106
- return regionHeight;
107
- const reflowed = estimateReflowRows(prev, cols);
108
- return Math.min(Math.max(0, rows - 1), reflowed);
109
- };
110
195
  const record = (bytes, shape, statics) => {
111
196
  if (shape === 'seed')
112
197
  stats.framesSeed += 1;
@@ -120,32 +205,159 @@ export function createScrollbackWriter(o) {
120
205
  stats.bytesWritten += bytes.length;
121
206
  return bytes;
122
207
  };
123
- const afterBatch = (lines, raw, cols) => {
124
- prev = lines;
125
- prevRaw = raw;
126
- regionHeight = lines.length;
208
+ /**
209
+ * Bring `top + capacity` back in line with the live `rows - 1` BEFORE the batch
210
+ * reads either of them (§6.11, [P0-2]).
211
+ *
212
+ * The `resize` event is dispatched a libuv turn after `stdout.rows` changed, so a
213
+ * frame can arrive on the new grid while the anchor still describes the old one.
214
+ * The pre-canvas writer healed that one batch later, by deriving `gap` from the
215
+ * current height; a canvas cannot, because `top` and `capacity` only ever change
216
+ * where they are measured — and the batch that ran on the stale grid would record
217
+ * the new height, leaving the late `onResize` with a delta of zero. On a terminal
218
+ * that never answers CPR that is a composer stuck mid-screen for the session.
219
+ */
220
+ const reconcile = (rows, cols) => {
221
+ if (anchor.kind !== 'pinned')
222
+ return;
223
+ if (cols !== undefined && lastWriteCols !== undefined && cols !== lastWriteCols) {
224
+ reset();
225
+ return;
226
+ }
227
+ const capacity = screenCapacity(rows);
228
+ const parked = anchor.top + anchor.capacity;
229
+ if (capacity === parked)
230
+ return;
231
+ if (capacity > parked) {
232
+ // The terminal grew below the cursor: the cursor did not move, so the canvas
233
+ // owns exactly the extra rows.
234
+ anchor = { kind: 'pinned', top: anchor.top, capacity: anchor.capacity + (capacity - parked) };
235
+ lastRows = rows;
236
+ return;
237
+ }
238
+ reset();
239
+ };
240
+ const afterCanvasBatch = (layout, heldNext, frame, raw, cols, rows) => {
241
+ prev = [...new Array(layout.gap).fill(''), ...heldNext, ...frame];
242
+ prevRawFrame = [...raw];
243
+ prevClippedFrame = [...frame];
244
+ canvasRows = prev.length;
245
+ held = [...heldNext];
246
+ gapRows = layout.gap;
247
+ frameRows = frame.length;
248
+ anchor = { kind: 'pinned', top: layout.top, capacity: layout.canvasRows };
127
249
  everWritten = true;
128
- resizePending = false;
129
250
  lastWriteCols = cols;
251
+ lastRows = rows;
252
+ anchorDirty = false;
253
+ seedDownToTop = 0;
254
+ scrolledRows += layout.scroll;
255
+ publishAnchorStats();
130
256
  // A feed larger than one batch continues on the next tick, region still parked.
131
257
  if (o.drain.pending() > 0) {
132
258
  repaintQueued = false;
133
259
  scheduleRepaint();
134
260
  }
135
261
  };
136
- const assemble = (lines, raw, rows, cols) => {
137
- const statics = clipStatics(o.drain.takeUpTo(maxBatch), cols);
262
+ const afterLegacyBatch = (raw, frame, cols, rows) => {
263
+ prev = [...frame];
264
+ prevRawFrame = [...raw];
265
+ prevClippedFrame = [...frame];
266
+ // The region IS the erasable block on this path, which is also the number a
267
+ // later measurement needs in order to know how far above the cursor its top is.
268
+ canvasRows = frame.length;
269
+ gapRows = 0;
270
+ frameRows = frame.length;
271
+ held = [];
272
+ everWritten = true;
273
+ lastWriteCols = cols;
274
+ lastRows = rows;
275
+ anchorDirty = false;
276
+ publishAnchorStats();
277
+ if (o.drain.pending() > 0) {
278
+ repaintQueued = false;
279
+ scheduleRepaint();
280
+ }
281
+ };
282
+ /**
283
+ * The pre-canvas append path: static rows above a region that is never redrawn.
284
+ * Byte-identical to the writer before either anchor existed (I-B7): `seedBytes` /
285
+ * `fullBytes` / `diffBytes`, `eraseUp = canvasRows` (= the frame height).
286
+ */
287
+ const assembleLegacy = (raw, statics, frame, rows, cols, mode) => {
138
288
  let out;
139
289
  if (prev === null) {
140
- out = record(seedBytes({ lines, statics, sync: o.sync, reseed: everWritten }), 'seed', statics.length);
290
+ out = record(seedBytes({ lines: frame, statics, sync: o.sync, reseed: everWritten }), 'seed', statics.length);
291
+ }
292
+ else if (mode === 'repaint') {
293
+ out = record(fullBytes({ lines: frame, statics, eraseUp: canvasRows, sync: o.sync }), 'repaint', statics.length);
141
294
  }
142
- else if (statics.length > 0 || lines.length !== prev.length || o.fullEveryFrame || resizePending) {
143
- out = record(fullBytes({ lines, statics, eraseUp: eraseDistance(rows, cols), sync: o.sync }), 'full', statics.length);
295
+ else if (statics.length > 0 || frame.length !== canvasRows || o.fullEveryFrame) {
296
+ out = record(fullBytes({ lines: frame, statics, eraseUp: canvasRows, sync: o.sync }), 'full', statics.length);
144
297
  }
145
298
  else {
146
- out = record(diffBytes({ lines, prev, sync: o.sync }), 'diff', 0);
299
+ out = record(diffBytes({ lines: frame, prev, sync: o.sync }), 'diff', 0);
147
300
  }
148
- afterBatch(lines, raw, cols);
301
+ afterLegacyBatch(raw, frame, cols, rows);
302
+ return out;
303
+ };
304
+ /**
305
+ * A canvas batch repaints EVERYTHING when the layout moved, and only the rows that
306
+ * differ when it did not. `layout.scroll > 0` forces the full shape for a reason
307
+ * the other four conditions do not cover: when the canvas is already the whole
308
+ * screen its height cannot change, so a batch that pushes rows out would otherwise
309
+ * be diffed against rows it has just lost.
310
+ */
311
+ const needsCanvasShape = (layout, frame, statics, mode) => {
312
+ if (mode === 'repaint')
313
+ return true;
314
+ return statics > 0 || layout.scroll > 0 || layout.gap !== gapRows ||
315
+ layout.canvasRows !== canvasRows || frame.length !== frameRows || o.fullEveryFrame;
316
+ };
317
+ const emitCanvas = (layout, content, kept, frame, statics, mode) => {
318
+ if (prev === null) {
319
+ return record(canvasSeedBytes({
320
+ downToTop: seedDownToTop,
321
+ gap: layout.gap, held: content, lines: frame, sync: o.sync, reseed: everWritten,
322
+ }), 'seed', statics);
323
+ }
324
+ if (needsCanvasShape(layout, frame, statics, mode)) {
325
+ return record(canvasBytes({
326
+ eraseUp: canvasRows, gap: layout.gap, held: content, lines: frame, sync: o.sync,
327
+ }), mode === 'repaint' ? 'repaint' : 'full', statics);
328
+ }
329
+ const shown = [...new Array(layout.gap).fill(''), ...kept, ...frame];
330
+ return record(diffBytes({ lines: shown, prev, sync: o.sync }), 'diff', 0);
331
+ };
332
+ /**
333
+ * THE ONE PLACE THE GEOMETRY IS COMPUTED (§6.2), shared by `transform` and
334
+ * `repaint` so the two can never disagree about where the region goes.
335
+ *
336
+ * Order is load-bearing: reconcile, then static rows, then the layout, then the
337
+ * bytes. `ED0`, the gap, the content, the region — in that order, always.
338
+ */
339
+ const assemble = (raw, rows, cols, mode) => {
340
+ reconcile(rows, cols);
341
+ const statics = clipStatics(o.drain.takeUpTo(maxBatch), cols);
342
+ const frame = clipFrame(raw, cols);
343
+ if (anchor.kind === 'unknown') {
344
+ return assembleLegacy(raw, statics, frame, rows, cols, mode);
345
+ }
346
+ const layout = resolveCanvasLayout({
347
+ capacity: anchor.capacity,
348
+ top: anchor.top,
349
+ heldRows: held.length,
350
+ statRows: statics.length,
351
+ frameRows: frame.length,
352
+ lead: prev === null && everWritten ? 1 : 0,
353
+ });
354
+ // The bytes carry every content row; the ones that do not fit leave through the
355
+ // bottom, and the terminal can only scroll away rows it was given. `held` keeps
356
+ // the suffix that is still on screen (I-B5).
357
+ const content = held.concat(statics);
358
+ const kept = layout.dropped > 0 ? content.slice(layout.dropped) : content;
359
+ const out = emitCanvas(layout, content, kept, frame, statics.length, mode);
360
+ afterCanvasBatch(layout, kept, frame, raw, cols, rows);
149
361
  return out;
150
362
  };
151
363
  const swallowTallFrame = () => {
@@ -162,7 +374,7 @@ export function createScrollbackWriter(o) {
162
374
  return '';
163
375
  };
164
376
  const countFallback = (counted) => {
165
- invalidate();
377
+ reset();
166
378
  if (counted) {
167
379
  stats.fallbacks += 1;
168
380
  if (stats.fallbacks === 1)
@@ -170,6 +382,26 @@ export function createScrollbackWriter(o) {
170
382
  }
171
383
  return null;
172
384
  };
385
+ /**
386
+ * Ink wrote a frame we cannot address (a one-row terminal). Record only what the
387
+ * FRAME tells us (P2-6): a frame is not a canvas, so on the append path it is the
388
+ * block (`canvasRows = H`), and on the canvas path nothing survives but the fact
389
+ * that the geometry is lost.
390
+ */
391
+ const passThrough = (lines) => {
392
+ if (lines === null)
393
+ return;
394
+ prevRawFrame = lines;
395
+ prevClippedFrame = lines;
396
+ frameRows = lines.length;
397
+ if (anchor.kind === 'pinned') {
398
+ reset();
399
+ return;
400
+ }
401
+ prev = lines;
402
+ canvasRows = lines.length;
403
+ gapRows = 0;
404
+ };
173
405
  const transform = (chunk) => {
174
406
  if (finalized)
175
407
  return '';
@@ -182,51 +414,46 @@ export function createScrollbackWriter(o) {
182
414
  const rows = liveRows();
183
415
  const lines = parseInkFrame(chunk);
184
416
  if (rows === undefined || rows < 2) {
185
- // Pass through, but keep `prev`/`H0` in step with Ink's own count (RV-6).
186
- if (lines) {
187
- prev = lines;
188
- prevRaw = lines;
189
- regionHeight = lines.length;
190
- }
417
+ passThrough(lines);
191
418
  return null;
192
419
  }
193
420
  if (lines === null)
194
421
  return countFallback(!firstChunk);
195
422
  if (lines.length + 1 > rows) {
196
423
  // Unreachable by construction (I-S1); never erase on a guess.
197
- invalidate();
424
+ reset();
198
425
  return '';
199
426
  }
200
- const cols = liveCols();
201
- return assemble(clipRegion(lines, cols), lines, rows, cols);
427
+ return assemble(lines, rows, liveCols(), 'frame');
202
428
  };
203
429
  const repaint = () => {
204
430
  repaintQueued = false;
205
- if (finalized || prev === null || prevRaw === null)
431
+ if (finalized || prevClippedFrame === null || prevRawFrame === null)
206
432
  return '';
207
- if (o.drain.pending() === 0 && !resizePending)
433
+ // `anchorDirty` is why a probe result arriving while the feed is idle lands at
434
+ // all: without it, "re-pin after a resize" would write zero bytes (P0-2).
435
+ if (o.drain.pending() === 0 && !anchorDirty)
208
436
  return '';
209
437
  const rows = liveRows();
210
438
  if (rows === undefined || rows < 2)
211
439
  return '';
212
- const cols = liveCols();
213
- const lines = prev;
214
- const statics = clipStatics(o.drain.takeUpTo(maxBatch), cols);
215
- const bytes = fullBytes({ lines, statics, eraseUp: eraseDistance(rows, cols), sync: o.sync });
216
- // A repaint is its own counter: `framesFull` counts frames INK produced.
217
- record(bytes, 'repaint', statics.length);
218
- afterBatch(lines, prevRaw, cols);
219
- return bytes;
440
+ return assemble(prevRawFrame, rows, liveCols(), 'repaint');
220
441
  };
221
442
  const finalize = (summary) => {
222
443
  if (finalized)
223
444
  return '';
224
445
  finalized = true;
225
- const statics = clipStatics(o.drain.takeAll(), liveCols());
446
+ const pending = clipStatics(o.drain.takeAll(), liveCols());
226
447
  // Unknown geometry after a foreign write: no safe erase, start on a fresh line.
227
- const eraseUp = prev === null && everWritten ? -1 : eraseDistance(liveRows() ?? 24, liveCols());
228
- const bytes = finalizeBytes({ eraseUp, statics, summary });
229
- stats.staticRowsWritten += statics.length;
448
+ // Otherwise erase the whole CANVAS — the gap included, or its blank rows stay
449
+ // behind as a dead band above the summary (I-B11).
450
+ const eraseUp = prev === null && everWritten ? -1 : canvasRows;
451
+ // ... and reprint what the erase removes. The content rows live INSIDE the canvas,
452
+ // so erasing it without them would delete the user's last screenful of conversation
453
+ // at exactly the moment they want to read it (the pre-canvas writer never had to:
454
+ // its static rows were already outside the erasable block).
455
+ const bytes = finalizeBytes({ eraseUp, statics: [...held, ...pending], summary });
456
+ stats.staticRowsWritten += pending.length;
230
457
  stats.bytesWritten += bytes.length;
231
458
  return bytes;
232
459
  };
@@ -235,21 +462,80 @@ export function createScrollbackWriter(o) {
235
462
  transform,
236
463
  repaint,
237
464
  finalize,
238
- invalidate,
465
+ invalidate: reset,
239
466
  onResize() {
240
467
  stats.resizes += 1;
241
468
  lastResizeAt = now();
242
469
  consecutiveGuard = 0;
243
470
  guardTripped = false;
244
- // Keep `prev`: the reflow estimate needs the lines the terminal re-flowed.
245
- if (prev !== null && !finalized)
246
- resizePending = true;
471
+ const rowsNow = liveRows();
472
+ const colsNow = liveCols();
473
+ // A column change re-flows what is already on screen: the cached rows no longer
474
+ // describe it, and erasing by their height would cut a line in half (D5).
475
+ if (colsNow !== undefined && lastWriteCols !== undefined && colsNow !== lastWriteCols) {
476
+ reset();
477
+ return;
478
+ }
479
+ if (rowsNow !== undefined && lastRows !== undefined) {
480
+ if (rowsNow < lastRows) {
481
+ // The terminal deleted rows; they are gone and must not be redrawn.
482
+ reset();
483
+ return;
484
+ }
485
+ if (anchor.kind === 'pinned') {
486
+ const capacity = screenCapacity(rowsNow);
487
+ const grown = Math.min(applyResizeGrowth(anchor.capacity, lastRows, rowsNow), capacity);
488
+ anchor = { kind: 'pinned', top: Math.max(0, capacity - grown), capacity: grown };
489
+ }
490
+ lastRows = rowsNow;
491
+ }
492
+ // Coalesced and deferred by the host (P0-3); `anchorReprobes` is NOT bumped
493
+ // here — a drag fires dozens of resize events for one probe (P1-5).
494
+ requestProbe();
247
495
  },
248
496
  cursorNeutralWrite() {
249
497
  // OSC 52 and friends move no cell and no cursor (I-S11).
250
498
  },
251
- regionRows: () => regionHeight,
499
+ regionRows: () => Math.max(0, frameRows),
252
500
  scrollbackStats: () => ({ ...stats, elidedEntries: o.drain.elided() }),
501
+ setAnchorSpaceBelow(spaceBelow, source) {
502
+ if (finalized)
503
+ return;
504
+ const rows = liveRows();
505
+ const capacity = screenCapacity(rows);
506
+ // I-B14: the report is "rows BELOW the cursor", and the canvas also still owns
507
+ // `canvasRows` rows ABOVE it, so the reachable height is their sum. Keeping only
508
+ // the first term collapses the canvas onto the parked row, and the next batch
509
+ // then reads its own visible content as an overflow and scrolls it away twice.
510
+ const reach = clampSpaceBelow(spaceBelow, rows) + canvasRows;
511
+ // The cap bounds only a STARTING canvas: clipping a live one would make the
512
+ // next erase reach above its own top (see SCROLLBACK_LIMITS.maxCanvasRows).
513
+ const ceiling = canvasRows === 0
514
+ ? Math.min(capacity, SCROLLBACK_LIMITS.maxCanvasRows)
515
+ : capacity;
516
+ const usable = Math.max(0, Math.min(reach, ceiling));
517
+ anchor = { kind: 'pinned', top: Math.max(0, capacity - usable), capacity: usable };
518
+ // A capped starting canvas begins BELOW the cursor row, so the batch that seeds
519
+ // it has to descend first: `spaceBelow - capacity` rows. Zero whenever the cap
520
+ // does not bind, and zero for every canvas that already exists.
521
+ seedDownToTop = Math.max(0, clampSpaceBelow(spaceBelow, rows) - usable);
522
+ // Only measurements AFTER the first are re-probes (P1-5): `/perf` promises a
523
+ // re-probe count, not a measurement count, and the startup measurement is
524
+ // reported through `anchorSource` instead.
525
+ if (anchorSource !== 'none')
526
+ anchorReprobes += 1;
527
+ anchorSource = source;
528
+ anchorDirty = true;
529
+ publishAnchorStats();
530
+ },
531
+ anchorStats: () => ({
532
+ state: anchor.kind === 'pinned' ? 'pinned' : 'unknown',
533
+ source: anchorSource,
534
+ canvasRows: anchor.kind === 'pinned' ? canvasRows : 0,
535
+ heldRows: anchor.kind === 'pinned' ? held.length : 0,
536
+ gapRows: anchor.kind === 'pinned' ? gapRows : 0,
537
+ top: anchor.kind === 'pinned' ? anchor.top : 0,
538
+ }),
253
539
  stats: () => ({
254
540
  framesTotal: stats.framesSeed + stats.framesFull + stats.framesDiff,
255
541
  framesDiffed: stats.framesDiff,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The TAIL WINDOW of the native-scroll live region
3
+ * (tui-full-height-live-region D6 / D7, I-F8 / I-F9).
4
+ *
5
+ * WHAT CHANGED AND WHY. The region used to show only the entries the reducer had not
6
+ * finished writing (`entries.slice(settledCount)`), and everything settled was printed
7
+ * into the terminal's scrollback the moment it settled. Once the region is the WHOLE
8
+ * screen (`liveRows = rows - 1`), a printed row is pushed off the top by the very next
9
+ * frame, so "print on settle" would leave the user staring at a screen of blanks with
10
+ * only the unfinished cards on it. So the region now shows the last `K` PRINTABLE
11
+ * entries - settled or not - and an entry is printed when it LEAVES that window.
12
+ *
13
+ * The window is defined by ENTRY COUNT, never by measured height: a single entry taller
14
+ * than the region is clipped from the top by `justifyContent="flex-end"` and stays in
15
+ * the window until `K` further entries arrive (README, known limitation 2). Measuring
16
+ * heights here would need the offscreen renderer on every frame, which is the cost this
17
+ * module exists to avoid.
18
+ *
19
+ * PURE: no Ink, no I/O, no clock. `promote()` (the writer's only feeder) takes
20
+ * `printBoundary`'s result and keeps its own `printedIds` authority, so "printed is
21
+ * forward-only" (RV-7) is unchanged.
22
+ *
23
+ * PRINTABILITY != VISIBILITY. `isExcluded` entries (a non-terminal `service` card) never
24
+ * print and never take a slot in the window; an entry that is in the window may still be
25
+ * scrolled out of sight by a taller neighbour (README, known limitation 5). The second
26
+ * gap is disclosed by `/perf`'s `window=<start>/<n>`, not by a second measurement.
27
+ */
28
+ import type { Entry } from '../../agent/reducer.js';
29
+ export interface TailWindow {
30
+ /** First index of the window inside the full entry list. */
31
+ start: number;
32
+ /** Entries inside the window, oldest first (excluded entries already dropped). */
33
+ entries: readonly Entry[];
34
+ }
35
+ /**
36
+ * The index of the first entry that is still on screen: count backwards up to
37
+ * `maxEntries` PRINTABLE entries. Everything before it has left the region and is the
38
+ * writer's business.
39
+ *
40
+ * `maxEntries <= 0` returns `entries.length` - an empty window, which is the old
41
+ * behaviour ("everything printable may print") and the reason the guard is written as
42
+ * an early return rather than as a loop bound.
43
+ */
44
+ export declare function tailWindowStart(entries: readonly Entry[], maxEntries: number): number;
45
+ /** `entries.slice(start)` with the entries that never print dropped. */
46
+ export declare function selectTailWindow(entries: readonly Entry[], maxEntries: number): TailWindow;
47
+ /**
48
+ * The print boundary: never print what the region is still showing.
49
+ *
50
+ * THIS IS THE ONLY PLACE THAT COMBINES "settled" WITH "in the window" (RV-P2-13), which
51
+ * is why the composition lives here instead of at the pump: `promote()` must keep
52
+ * receiving one number, and the two authorities it is derived from - `settledCount`
53
+ * (what may print at all) and `tailWindowStart` (what is still visible) - then cannot
54
+ * drift apart in a future edit.
55
+ *
56
+ * `settleAllEntries` is the OTHER authority, not a duplicate: it answers "what must be
57
+ * printed when the conversation is being torn down", while this answers "how far may
58
+ * this frame print".
59
+ */
60
+ export declare function printBoundary(entries: readonly Entry[], runActive: boolean, maxEntries: number): number;