@aragon-agent/cli 0.6.33 → 0.6.34

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.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * `useScrollbackView` — the promotion pipeline of native-scroll mode
3
- * (tui-native-terminal-scroll §4.4 / §6.2, tui-full-height-live-region D3/D4/D7/D8).
3
+ * (tui-native-terminal-scroll §4.4 / §6.2, tui-full-height-live-region D3/D4/D7/D8,
4
+ * tui-final-reply-truncated-unscrollable A-prime).
4
5
  *
5
6
  * `App` calls it ALWAYS (hooks cannot be conditional); `enabled: false` makes every
6
7
  * effect a no-op and keeps `scrollMode: 'app'` byte-identical to the pre-feature
@@ -12,8 +13,15 @@
12
13
  * writer. Entries still on screen are never printed - at `H = rows - 1` a printed
13
14
  * row is pushed back off the top by the next frame, and a second copy in the
14
15
  * scrollback would be a duplicate the user meets by scrolling up (I-F8).
15
- * 2. Computes the live-region budget, the window and the tail rows for the shell.
16
- * 3. Flushes the window before either reset path (`/clear`, `--resume`) and on exit,
16
+ * 2. Gives way at the TOP (tui-final-reply-truncated-unscrollable). The window is still
17
+ * counted in ENTRIES, so a settled answer taller than the box used to sit inside the
18
+ * window forever, clipped by `justifyContent="flex-end"`, in a mode whose wheel only
19
+ * scrolls the terminal's scrollback: the clipped rows existed nowhere the user could
20
+ * reach. The rows the box hides are now measured (`overflow-print.ts`) and printed, so
21
+ * `scrollback + screen` covers every row exactly once. Screen unchanged, reachability
22
+ * fixed.
23
+ * 3. Computes the live-region budget, the window and the tail rows for the shell.
24
+ * 4. Flushes the window before either reset path (`/clear`, `--resume`) and on exit,
17
25
  * so "on screen but not printed yet" can never become "lost" (D8 / I-F12).
18
26
  *
19
27
  * THE HEADER AND THE OPENER ARE NOT PRINTED HERE ANY MORE (D3/D4). They are slots of
@@ -27,20 +35,30 @@
27
35
  * issued from inside the main tree's commit (a layout effect) is deferred until that
28
36
  * commit finishes, so its frame would not exist when the effect reads it. A microtask
29
37
  * runs after the commit has unwound, where the offscreen render is synchronous again.
30
- * Everything the pass needs is read from refs for that reason.
31
38
  *
32
- * `printedIds` is the ONLY authority on "already printed" (RV-7). The cursor is just
33
- * a hint for where to resume scanning: entries leave the list by retain-ring
34
- * trimming AND by reducer deletion (empty settled assistants, abandoned tool
35
- * cards), so an id-keyed cursor can dangle, and then the only correct answer is to
36
- * rescan and let `printedIds` skip.
39
+ * THE PASS READS THE COMMITTED FRAME, NOT THE LATEST RENDER. `latest` is refreshed during
40
+ * render, so it can be a frame ahead of the screen; `committed` is written by a layout
41
+ * effect and therefore describes exactly the frame the user is looking at. With
42
+ * entry-granular bookkeeping the difference was harmless (printing was idempotent per
43
+ * entry); with ROW-granular bookkeeping it would slice this frame's picture with another
44
+ * frame's row numbers, and the result is a duplicate or a hole.
45
+ *
46
+ * `printed` is the ONLY authority on "already printed" (RV-7): a `Map` from entry id to
47
+ * the leading rows already handed to the writer, and the width they were measured at. The
48
+ * cursor is just a hint for where to resume scanning: entries leave the list by retain-ring
49
+ * trimming AND by reducer deletion (empty settled assistants, abandoned tool cards), so an
50
+ * id-keyed cursor can dangle, and then the only correct answer is to rescan and let
51
+ * `printed` skip.
37
52
  */
38
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
53
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef } from 'react';
54
+ import { estimateEntryRows } from '../layout/virtual-window.js';
39
55
  import { buildScrollbackBudget } from './budget.js';
40
56
  import { buildLivePreview } from './live-preview.js';
41
57
  import { SCROLLBACK_LIMITS } from './limits.js';
58
+ import { capPrintedRows, planOverflowPrint, reflowSeamRow, } from './overflow-print.js';
42
59
  import { createEntryRenderer } from './renderer.js';
43
- import { isExcluded, settleAllEntries } from './settled-boundary.js';
60
+ import { isEntryStable, isExcluded, settleAllEntries } from './settled-boundary.js';
61
+ import { resolveTailAnchor } from './tail-anchor.js';
44
62
  import { printBoundary, selectTailWindow } from './tail-window.js';
45
63
  export function createScrollbackBridge(parts) {
46
64
  return {
@@ -63,34 +81,57 @@ function printableBefore(entries, index) {
63
81
  }
64
82
  /**
65
83
  * Ids leave `entries` by retain-ring trimming and never come back (ids are unique
66
- * per session), so `printedIds` is trimmed to the live list once it grows well past
84
+ * per session), so both row maps are trimmed to the live list once they grow well past
67
85
  * it. Only ids still present can be re-evaluated, so dropping the rest is lossless.
68
86
  */
69
- function pruneEntryIds(printed, entries) {
70
- if (printed.size <= Math.max(2000, entries.length * 3))
87
+ function pruneEntryIds(entries, printed, heights) {
88
+ const bound = Math.max(2000, entries.length * 3);
89
+ if (printed.size <= bound && heights.size <= bound)
71
90
  return;
72
91
  const live = new Set(entries.map((entry) => entry.id));
73
- for (const id of printed)
92
+ for (const id of [...printed.keys()])
74
93
  if (!live.has(id))
75
94
  printed.delete(id);
95
+ // Height keys are `id|cols|expanded|thinking`; only the id part decides liveness.
96
+ for (const key of [...heights.keys()]) {
97
+ if (!live.has(key.slice(0, key.indexOf('|'))))
98
+ heights.delete(key);
99
+ }
100
+ }
101
+ /** The word the expanded-copy header uses for one entry kind. */
102
+ function expandedLabel(entry) {
103
+ if (entry.kind === 'tool')
104
+ return entry.name;
105
+ // The escape hatch exists for the Agent's ANSWER (known limitation 2 promised it), so the
106
+ // header says what the user pressed Ctrl+O for instead of the internal kind name.
107
+ if (entry.kind === 'assistant')
108
+ return 'answer';
109
+ return entry.kind;
76
110
  }
77
111
  export function useScrollbackView(args) {
78
- const { enabled, bridge, entries, runActive, theme, caps, density, thinkingVisible, reducedMotion, rows, cols, overlayOpen, draftRows, popupRows, statusExpanded, forceInactive = false, itemCount, panelEnabled, teamRowsNatural, } = args;
112
+ const { enabled, bridge, entries, runActive, theme, caps, density, thinkingVisible, reducedMotion, rows, cols, overlayOpen, draftRows, popupRows, statusExpanded, forceInactive = false, itemCount, panelEnabled, teamRowsNatural, expandedToolIds, } = args;
79
113
  const contentCols = Math.max(1, cols - 1);
80
- // Everything the asynchronous pass reads travels here, refreshed on every render.
114
+ // Everything the asynchronous EXIT and RESET paths read travels here, refreshed on every
115
+ // render (see `committed` for why the promotion pass does not read it).
81
116
  const latest = useRef({
82
- entries, runActive, contentCols,
117
+ entries, runActive, contentCols, previewCols: contentCols, expandedToolIds,
83
118
  options: { theme, caps, density, thinkingVisible, reducedMotion },
84
119
  });
85
120
  latest.current = {
86
- entries, runActive, contentCols,
121
+ entries, runActive, contentCols, previewCols: latest.current.previewCols, expandedToolIds,
87
122
  options: { theme, caps, density, thinkingVisible, reducedMotion },
88
123
  };
89
124
  const rendererRef = useRef(null);
90
- const printedIds = useRef(new Set());
125
+ /** THE authority on "already printed" (RV-7), per entry and per row. */
126
+ const printed = useRef(new Map());
127
+ /** Measured heights, `id|cols|expanded|thinking` -> rows. Only STABLE entries are stored. */
128
+ const heights = useRef(new Map());
91
129
  /** A resume hint only (RV-7): the id of the last entry the previous pass covered. */
92
130
  const cursorRef = useRef(null);
93
131
  const pendingReset = useRef(null);
132
+ const headRowsRef = useRef(0);
133
+ /** Guards the "measure, then re-render so the anchor can use it" nudge against a loop. */
134
+ const nudgedRef = useRef(null);
94
135
  // Band hysteresis (release-gate-and-native-panels §6.7): the writer re-lays out the
95
136
  // canvas on every batch, so a band that shrank for two frames would make everything
96
137
  // above the composer jump.
@@ -99,6 +140,9 @@ export function useScrollbackView(args) {
99
140
  const heldGeometry = useRef({ rows, cols });
100
141
  const pumpQueued = useRef(false);
101
142
  const mountedRef = useRef(true);
143
+ const committed = useRef(null);
144
+ /** Bumped when a measurement has to reach `LiveTail` before the next pass may print. */
145
+ const [measureEpoch, bumpMeasureEpoch] = useReducer((n) => n + 1, 0);
102
146
  const ensureRenderer = useCallback(() => {
103
147
  if (!rendererRef.current)
104
148
  rendererRef.current = createEntryRenderer(latest.current.options);
@@ -110,38 +154,124 @@ export function useScrollbackView(args) {
110
154
  return;
111
155
  bridge.drain.enqueue(rowsToPrint, { id, kind });
112
156
  }, [bridge]);
113
- /** Promote `list[start..end)`; returns true when it stopped early because the feed is full. */
114
- const promote = useCallback((list, end, limit) => {
115
- const renderer = ensureRenderer();
157
+ const heightKey = useCallback((entry, colsNow, expanded) => `${entry.id}|${colsNow}|${expanded ? 1 : 0}|${thinkingVisible ? 1 : 0}`, [thinkingVisible]);
158
+ /** Rows of one entry at `colsNow`, measured once per (id, cols, flags) and cached. */
159
+ const measure = useCallback((entry, prev, colsNow, expanded) => {
160
+ const key = heightKey(entry, colsNow, expanded);
161
+ const cached = heights.current.get(key);
162
+ if (cached !== undefined)
163
+ return cached;
164
+ const measured = ensureRenderer().renderEntryRows(entry, prev, colsNow, expanded).length;
165
+ heights.current.set(key, measured);
166
+ return measured;
167
+ }, [ensureRenderer, heightKey]);
168
+ /**
169
+ * Which width the REMAINDER of an entry must be printed at.
170
+ *
171
+ * Row numbers are width-relative, so a recorded width that this frame still legitimately
172
+ * renders at is the only width whose row numbers can be trusted (`known`); anything else
173
+ * is a reflow, and the caller starts over behind a seam row.
174
+ */
175
+ const resolvePrintWidth = useCallback((state, wanted, known) => {
176
+ if (!state || state.cols === wanted)
177
+ return wanted;
178
+ return known.includes(state.cols) ? state.cols : wanted;
179
+ }, []);
180
+ /**
181
+ * Print everything of `list[index]` that is not in the scrollback yet, in ONE go.
182
+ *
183
+ * The entry has left the region, so all of it may print; what is left is the remainder
184
+ * behind the rows the top give-way already printed (`printed.rows`). A width change makes
185
+ * those row numbers meaningless, so it prints a seam and starts over - a bounded
186
+ * repetition instead of a hole.
187
+ */
188
+ const printRemainder = useCallback((list, index, wantedCols, known, expanded) => {
189
+ const entry = list[index];
190
+ const state = printed.current.get(entry.id);
191
+ if (state?.done)
192
+ return;
193
+ const width = resolvePrintWidth(state, wantedCols, known);
194
+ const restart = state !== undefined && state.cols !== width;
195
+ const from = restart ? 0 : state?.rows ?? 0;
196
+ const body = ensureRenderer().renderEntryRows(entry, printableBefore(list, index), width, expanded.includes(entry.id));
197
+ const seam = restart ? reflowSeamRow(state.rows, state.cols, width) : null;
198
+ const tail = body.slice(from);
199
+ const capped = capPrintedRows(tail, from);
200
+ enqueue(seam === null ? capped : [seam, ...capped], entry.id, entry.kind);
201
+ printed.current.set(entry.id, { rows: body.length, cols: width, done: true });
202
+ }, [enqueue, ensureRenderer, resolvePrintWidth]);
203
+ /** Expanded ids of a frame, as a list so a callback does not depend on the object. */
204
+ const expandedIdsOf = useCallback((ids) => Object.keys(ids).filter((id) => ids[id] === true), []);
205
+ /** The window as full-list indices (excluded entries already dropped), oldest first. */
206
+ const windowItemsOf = useCallback((frame, start) => {
207
+ const items = [];
208
+ for (let i = start; i < frame.entries.length; i += 1) {
209
+ const entry = frame.entries[i];
210
+ if (!isExcluded(entry))
211
+ items.push({ index: i, entry });
212
+ }
213
+ return items;
214
+ }, []);
215
+ /**
216
+ * The window's measured rows, or `undefined` when the measurement cannot be trusted.
217
+ *
218
+ * TWO conditions, both structural rather than defensive. Every entry must be STABLE: an
219
+ * entry the reducer may still rewrite has a height that changes, and printing part of it
220
+ * would put a version of the answer into the scrollback that can never be corrected. And
221
+ * the caller has already checked that the wall does not sit before the window: rows
222
+ * behind an unprintable entry would land out of conversation order (I-S3), which is the
223
+ * one mistake an append-only medium cannot take back.
224
+ */
225
+ const measureWindow = useCallback((frame, items) => {
226
+ const last = frame.entries.length - 1;
227
+ for (const item of items) {
228
+ if (!isEntryStable(item.entry, frame.runActive, item.index === last))
229
+ return undefined;
230
+ }
231
+ const expanded = expandedIdsOf(frame.expandedToolIds);
232
+ return items.map((item) => {
233
+ const state = printed.current.get(item.entry.id);
234
+ const comparable = state !== undefined && state.cols === frame.previewCols && !state.done;
235
+ return {
236
+ entry: item.entry,
237
+ rows: measure(item.entry, printableBefore(frame.entries, item.index), frame.previewCols, expanded.includes(item.entry.id)),
238
+ printed: comparable ? state.rows : 0,
239
+ stable: true,
240
+ };
241
+ });
242
+ }, [expandedIdsOf, measure]);
243
+ /** Promote `list[start..end)`; reports when it stopped early because the feed is full. */
244
+ const promote = useCallback((frame, end, limit, known) => {
245
+ const list = frame.entries;
246
+ const expanded = expandedIdsOf(frame.expandedToolIds);
116
247
  let start = 0;
117
248
  const cursor = cursorRef.current;
118
249
  if (cursor && list[cursor.index]?.id === cursor.id)
119
250
  start = cursor.index + 1;
120
- let promoted = 0;
251
+ let used = 0;
121
252
  let lastIndex = start - 1;
122
253
  const remember = () => {
123
254
  cursorRef.current = lastIndex >= 0 ? { id: list[lastIndex].id, index: lastIndex } : null;
124
255
  };
125
256
  for (let i = start; i < end; i += 1) {
126
257
  const entry = list[i];
127
- if (isExcluded(entry) || printedIds.current.has(entry.id)) {
258
+ // A partially printed entry is NOT finished: it is processed until `done`, so a row
259
+ // range the give-way left behind is still picked up here (P-5).
260
+ if (isExcluded(entry) || printed.current.get(entry.id)?.done === true) {
128
261
  lastIndex = i;
129
262
  continue;
130
263
  }
131
- if (promoted >= limit) {
264
+ if (used >= limit) {
132
265
  remember();
133
- return true;
266
+ return { limited: true, used };
134
267
  }
135
- const rowsToPrint = renderer.renderEntry(entry, printableBefore(list, i), latest.current.contentCols);
136
- enqueue(rowsToPrint, entry.id, entry.kind);
137
- // A rendered-to-nothing entry is registered too, or it would be retried forever.
138
- printedIds.current.add(entry.id);
139
- promoted += 1;
268
+ printRemainder(list, i, frame.contentCols, known, expanded);
269
+ used += 1;
140
270
  lastIndex = i;
141
271
  }
142
272
  remember();
143
- return false;
144
- }, [ensureRenderer, enqueue]);
273
+ return { limited: false, used };
274
+ }, [expandedIdsOf, printRemainder]);
145
275
  /**
146
276
  * THE ONE PRINT LOOP (RV-P0-2 / RV-P2-12): every not-yet-printed entry of `list`,
147
277
  * oldest first, with no per-pass limit.
@@ -160,24 +290,24 @@ export function useScrollbackView(args) {
160
290
  const flushUnprinted = useCallback((list) => {
161
291
  if (!bridge)
162
292
  return;
163
- const renderer = ensureRenderer();
293
+ const known = [latest.current.contentCols, latest.current.previewCols];
294
+ const expanded = expandedIdsOf(latest.current.expandedToolIds);
164
295
  for (const entry of settleAllEntries(list)) {
165
- if (printedIds.current.has(entry.id))
296
+ if (printed.current.get(entry.id)?.done === true)
166
297
  continue;
167
- enqueue(renderer.renderEntry(entry, printableBefore(list, list.indexOf(entry)), latest.current.contentCols), entry.id, entry.kind);
168
- printedIds.current.add(entry.id);
298
+ printRemainder(list, list.indexOf(entry), latest.current.contentCols, known, expanded);
169
299
  }
170
- }, [bridge, ensureRenderer, enqueue]);
300
+ }, [bridge, expandedIdsOf, printRemainder]);
171
301
  const applyPendingReset = useCallback(() => {
172
302
  const reset = pendingReset.current;
173
303
  if (!reset || latest.current.entries === reset.captured)
174
304
  return;
175
305
  pendingReset.current = null;
176
306
  // ORDER IS THE CONTRACT (AC-F16): flush the OLD list, then the separator, then
177
- // forget what was printed. Clearing `printedIds` first would make the flush print
307
+ // forget what was printed. Clearing `printed` first would make the flush print
178
308
  // the whole conversation a second time.
179
309
  flushUnprinted(reset.captured);
180
- printedIds.current = new Set();
310
+ printed.current = new Map();
181
311
  cursorRef.current = null;
182
312
  enqueue([reset.kind === 'clear' ? CLEARED_ROW : RESTORED_ROW], `reset:${Date.now()}`, 'notice');
183
313
  }, [enqueue, flushUnprinted]);
@@ -188,56 +318,109 @@ export function useScrollbackView(args) {
188
318
  pumpQueued.current = true;
189
319
  queueMicrotask(() => pumpRef.current());
190
320
  }, []);
321
+ const continueOnDrain = useCallback(() => {
322
+ if (!bridge)
323
+ return;
324
+ bridge.drain.onDrained = () => {
325
+ bridge.drain.onDrained = null;
326
+ schedulePump();
327
+ };
328
+ bridge.requestFlush();
329
+ }, [bridge, schedulePump]);
330
+ /**
331
+ * TOP GIVE-WAY: print the rows the box hides, when it hides them from the TOP.
332
+ *
333
+ * The order of the two decisions is not cosmetic. `frame.anchor` is what the SCREEN is
334
+ * doing; `planOverflowPrint` answers what is off it. Printing while the frame is
335
+ * `top`-anchored would print the rows the user is looking at (Ink clips the BOTTOM in that
336
+ * branch) and leave the ones they cannot see - a duplicate and a hole at once. So a frame
337
+ * whose anchor the measurement contradicts is nudged once and the print waits for the
338
+ * frame that agrees.
339
+ *
340
+ * @returns How many segments were enqueued.
341
+ */
342
+ const printOverflow = useCallback((frame, allowance) => {
343
+ if (frame.tailRows <= 0)
344
+ return 0;
345
+ const maxEntries = SCROLLBACK_LIMITS.liveTailMaxEntries;
346
+ const { start } = selectTailWindow(frame.entries, maxEntries);
347
+ // The wall before the window is still a wall: the give-way would print rows that come
348
+ // after an unprintable entry (I-S3 / A12).
349
+ if (printBoundary(frame.entries, frame.runActive, maxEntries) < start)
350
+ return 0;
351
+ const items = windowItemsOf(frame, start);
352
+ if (items.length === 0)
353
+ return 0;
354
+ const measured = measureWindow(frame, items);
355
+ if (!measured)
356
+ return 0;
357
+ const total = measured.reduce((sum, item) => sum + item.rows, 0);
358
+ if (frame.measuredRows !== total) {
359
+ // `LiveTail` anchored this frame from an estimate (or from an older measurement).
360
+ // Hand it the number, once per distinct picture, and print on the frame that agrees.
361
+ const nudgeKey = `${start}|${items.length}|${total}`;
362
+ if (nudgedRef.current !== nudgeKey) {
363
+ nudgedRef.current = nudgeKey;
364
+ bumpMeasureEpoch();
365
+ }
366
+ }
367
+ if (frame.anchor !== 'bottom')
368
+ return 0;
369
+ const plan = planOverflowPrint(measured, frame.tailRows);
370
+ const expanded = expandedIdsOf(frame.expandedToolIds);
371
+ let used = 0;
372
+ for (const segment of plan.segments) {
373
+ if (used >= allowance) {
374
+ continueOnDrain();
375
+ return used;
376
+ }
377
+ const item = measured[segment.index];
378
+ const state = printed.current.get(item.entry.id);
379
+ // The segment's row numbers live in the box's index space, so the rows must be
380
+ // rendered and printed at that width; a stale recorded width restarts behind a seam.
381
+ const restart = state !== undefined && state.cols !== frame.previewCols;
382
+ const from = restart ? 0 : state?.rows ?? 0;
383
+ const body = ensureRenderer().renderEntryRows(item.entry, printableBefore(frame.entries, items[segment.index].index), frame.previewCols, expanded.includes(item.entry.id));
384
+ const slice = body.slice(from, Math.max(from, segment.toRow));
385
+ const out = capPrintedRows(slice, from);
386
+ const seam = restart ? reflowSeamRow(state.rows, state.cols, frame.previewCols) : null;
387
+ enqueue(seam === null ? out : [seam, ...out], item.entry.id, item.entry.kind);
388
+ const capped = out.length !== slice.length;
389
+ const finished = capped || segment.toRow >= item.rows;
390
+ printed.current.set(item.entry.id, {
391
+ rows: finished ? item.rows : segment.toRow,
392
+ cols: frame.previewCols,
393
+ done: finished,
394
+ });
395
+ headRowsRef.current += out.length;
396
+ used += 1;
397
+ }
398
+ return used;
399
+ }, [continueOnDrain, enqueue, ensureRenderer, expandedIdsOf, measureWindow, windowItemsOf]);
191
400
  /** The promotion pass. Runs in a microtask (see the file header). */
192
401
  pumpRef.current = () => {
193
402
  pumpQueued.current = false;
194
- if (!mountedRef.current || !bridge)
403
+ const frame = committed.current;
404
+ if (!mountedRef.current || !bridge || !frame)
195
405
  return;
196
406
  applyPendingReset();
197
- const { entries: list, runActive: active } = latest.current;
407
+ const known = [frame.contentCols, frame.previewCols];
198
408
  const limit = Math.max(1, SCROLLBACK_LIMITS.feedCapacity - bridge.drain.pendingEntries() - 1);
199
409
  // `printBoundary` = min(settled, window start): the region keeps what it shows.
200
- const end = printBoundary(list, active, SCROLLBACK_LIMITS.liveTailMaxEntries);
201
- const more = promote(list, end, limit);
202
- pruneEntryIds(printedIds.current, list);
203
- if (more) {
410
+ const end = printBoundary(frame.entries, frame.runActive, SCROLLBACK_LIMITS.liveTailMaxEntries);
411
+ const promoted = promote(frame, end, limit, known);
412
+ pruneEntryIds(frame.entries, printed.current, heights.current);
413
+ if (promoted.limited) {
204
414
  // Continue once the writer has consumed what is queued (a restored transcript
205
415
  // longer than the feed is promoted in waves rather than folded into a marker).
206
- bridge.drain.onDrained = () => {
207
- bridge.drain.onDrained = null;
208
- schedulePump();
209
- };
210
- bridge.requestFlush();
416
+ continueOnDrain();
417
+ return;
211
418
  }
419
+ // ORDER: everything that has left the window is older than everything in it, so the
420
+ // give-way only ever runs in a pass that promoted everything it had to.
421
+ if (printOverflow(frame, Math.max(0, limit - promoted.used)) > 0)
422
+ bridge.requestFlush();
212
423
  };
213
- useLayoutEffect(() => {
214
- if (enabled && bridge)
215
- schedulePump();
216
- }, [enabled, bridge, entries, runActive, schedulePump]);
217
- const settleAll = useCallback(() => {
218
- flushUnprinted(latest.current.entries);
219
- }, [flushUnprinted]);
220
- // Shutdown hook for the exit paths. `restore()` runs it AFTER the tree is gone
221
- // (normal exit) or while it is alive (signal), so it renders synchronously and then
222
- // releases the offscreen instance. The unmount cleanup deliberately renders nothing:
223
- // it runs inside the main tree's commit, where an offscreen render is deferred.
224
- const settleAllRef = useRef(settleAll);
225
- settleAllRef.current = settleAll;
226
- useEffect(() => {
227
- mountedRef.current = true;
228
- if (!enabled || !bridge)
229
- return undefined;
230
- bridge.settleAllNow = () => {
231
- try {
232
- settleAllRef.current();
233
- }
234
- finally {
235
- rendererRef.current?.dispose();
236
- rendererRef.current = null;
237
- }
238
- };
239
- return () => { mountedRef.current = false; };
240
- }, [enabled, bridge]);
241
424
  // --- Live region budget and the tail window. ------------------------------
242
425
  // The window is the last K printable entries, settled or not (D6/D7). `slice` +
243
426
  // `tailWindowStart` is `tail-window.ts`'s definition, and `printBoundary` uses the
@@ -281,12 +464,88 @@ export function useScrollbackView(args) {
281
464
  }
282
465
  // Print keeps `contentCols`; only the live tail gives up the rail's columns (I-5).
283
466
  const previewCols = budget ? budget.previewCols : contentCols;
467
+ const tailRows = budget ? budget.liveTailRows : 0;
468
+ // `previewCols` is only known after the budget, so the copy above carries the PREVIOUS
469
+ // frame's value; this line hands the exit and reset paths the current one. The promotion
470
+ // pass does not read it - it reads `committed`, written below.
471
+ latest.current.previewCols = previewCols;
472
+ const lastIndex = entries.length - 1;
473
+ const indexById = useMemo(() => new Map(entries.map((entry, index) => [entry.id, index])), [entries]);
474
+ /**
475
+ * The window's measured height, when every entry in it is stable AND already measured at
476
+ * this geometry. This is the number `LiveTail` anchors with, and the number the pump
477
+ * compares itself against, so both agree about which frame the user is looking at.
478
+ */
479
+ const measuredTailRows = useMemo(() => {
480
+ if (!enabled || tailRows <= 0 || tailEntries.length === 0)
481
+ return undefined;
482
+ let total = 0;
483
+ for (const entry of tailEntries) {
484
+ if (!isEntryStable(entry, runActive, indexById.get(entry.id) === lastIndex)) {
485
+ return undefined;
486
+ }
487
+ const cached = heights.current.get(heightKey(entry, previewCols, expandedToolIds[entry.id] === true));
488
+ if (cached === undefined)
489
+ return undefined;
490
+ total += cached;
491
+ }
492
+ return total;
493
+ // `heights` is a ref and `measureEpoch` is what fills it; every OTHER dependency is an
494
+ // input of the cache key, so a stale answer is impossible.
495
+ // eslint-disable-next-line react-hooks/exhaustive-deps
496
+ }, [enabled, tailRows, tailEntries, runActive, indexById, lastIndex, previewCols,
497
+ expandedToolIds, thinkingVisible, measureEpoch, heightKey]);
498
+ const anchor = resolveTailAnchor({
499
+ entries: tailEntries,
500
+ budgetRows: tailRows,
501
+ rowOf: (entry) => estimateEntryRows(entry, previewCols, density, expandedToolIds[entry.id] === true, thinkingVisible),
502
+ measuredRows: measuredTailRows,
503
+ });
284
504
  const liveTailLines = useMemo(() => enabled && budget && budget.liveTailRows > 0
285
505
  ? buildLivePreview({
286
506
  unsettled: tailEntries, cols: previewCols, rows: budget.liveTailRows,
287
507
  thinkingVisible,
288
508
  })
289
509
  : [], [enabled, budget, tailEntries, previewCols, thinkingVisible]);
510
+ // THE COMMIT HOOK. Every commit records the frame the screen is showing, so the microtask
511
+ // pass can never slice this frame's picture with another frame's numbers. It is the last
512
+ // layout effect in this hook on purpose: everything above is render-time state.
513
+ useLayoutEffect(() => {
514
+ committed.current = {
515
+ entries, runActive, contentCols, previewCols, tailRows, anchor, measuredRows: measuredTailRows,
516
+ expandedToolIds,
517
+ };
518
+ });
519
+ // Only a content change (or a measurement that must reach `LiveTail`) needs a pass; a
520
+ // resize is the writer's business, exactly as before.
521
+ useLayoutEffect(() => {
522
+ if (enabled && bridge)
523
+ schedulePump();
524
+ }, [enabled, bridge, entries, runActive, measureEpoch, schedulePump]);
525
+ const settleAll = useCallback(() => {
526
+ flushUnprinted(latest.current.entries);
527
+ }, [flushUnprinted]);
528
+ // Shutdown hook for the exit paths. `restore()` runs it AFTER the tree is gone
529
+ // (normal exit) or while it is alive (signal), so it renders synchronously and then
530
+ // releases the offscreen instance. The unmount cleanup deliberately renders nothing:
531
+ // it runs inside the main tree's commit, where an offscreen render is deferred.
532
+ const settleAllRef = useRef(settleAll);
533
+ settleAllRef.current = settleAll;
534
+ useEffect(() => {
535
+ mountedRef.current = true;
536
+ if (!enabled || !bridge)
537
+ return undefined;
538
+ bridge.settleAllNow = () => {
539
+ try {
540
+ settleAllRef.current();
541
+ }
542
+ finally {
543
+ rendererRef.current?.dispose();
544
+ rendererRef.current = null;
545
+ }
546
+ };
547
+ return () => { mountedRef.current = false; };
548
+ }, [enabled, bridge]);
290
549
  const reprintExpanded = useCallback((entryId) => {
291
550
  if (!enabled || !bridge)
292
551
  return false;
@@ -295,12 +554,29 @@ export function useScrollbackView(args) {
295
554
  if (index < 0)
296
555
  return false;
297
556
  const entry = list[index];
298
- const body = ensureRenderer().renderEntry(entry, undefined, latest.current.contentCols, true);
299
- if (body.length === 0)
300
- return false;
301
- enqueue([`-- expanded: ${entry.kind === 'tool' ? entry.name : entry.kind} --`, ...body], `expanded:${entryId}:${Date.now()}`, entry.kind);
557
+ // THE COPY IS RENDERED IN A MICROTASK, NOT IN THE KEY HANDLER THAT ASKED FOR IT.
558
+ // `Ctrl+O` arrives on the stdin stream, and an offscreen render issued from there is
559
+ // SCHEDULED rather than flushed - the reconciler sees a pending update, the frame the
560
+ // caller reads back is still empty, and the copy degrades to
561
+ // `[aragon] entry ... could not be rendered`. A microtask runs after the input handler
562
+ // has unwound, where the offscreen render is synchronous again: the same rule the
563
+ // promotion pass follows (see the file header). The answer is therefore "this entry
564
+ // exists and a copy is on its way", which is what the caller's toast needs.
565
+ //
566
+ // THE ID IS STABLE ON PURPOSE. `expanded:<entryId>` makes the copy greppable and keeps
567
+ // the key purely user-driven; it is not a dedupe key, because the drain is a FIFO that
568
+ // never coalesces, so a second press prints a second copy - the behaviour the tool cards
569
+ // have always had, and the reason the stable id is safe to use here.
570
+ queueMicrotask(() => {
571
+ if (!mountedRef.current || !bridge)
572
+ return;
573
+ const body = ensureRenderer().renderEntry(entry, undefined, latest.current.contentCols, true);
574
+ if (body.length === 0)
575
+ return;
576
+ enqueue([`-- expanded: ${expandedLabel(entry)} --`, ...body], `expanded:${entryId}`, entry.kind);
577
+ });
302
578
  return true;
303
- }, [enabled, bridge, ensureRenderer, enqueue]);
579
+ }, [bridge, enabled, enqueue, ensureRenderer]);
304
580
  const notifyReset = useCallback((kind) => {
305
581
  if (!enabled)
306
582
  return;
@@ -309,6 +585,10 @@ export function useScrollbackView(args) {
309
585
  pendingReset.current = { kind, captured: latest.current.entries };
310
586
  }, [enabled]);
311
587
  return {
312
- budget, liveTailLines, tailEntries, windowStart, reprintExpanded, settleAll, notifyReset,
588
+ budget, liveTailLines, tailEntries, windowStart, measuredTailRows,
589
+ // A GETTER, like `perfRef`'s `scrollbar`: the counter moves in a microtask, so a value
590
+ // captured at render time would report the frame BEFORE the one `/perf` is asked about.
591
+ get headRows() { return headRowsRef.current; },
592
+ reprintExpanded, settleAll, notifyReset,
313
593
  };
314
594
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.33",
3
+ "version": "0.6.34",
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.45",
59
+ "@aragon-agent/core": "^0.2.46",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",