@aragon-agent/cli 0.6.32 → 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.
@@ -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;
@@ -0,0 +1,73 @@
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 { isExcluded, settledCount } from './settled-boundary.js';
29
+ /**
30
+ * The index of the first entry that is still on screen: count backwards up to
31
+ * `maxEntries` PRINTABLE entries. Everything before it has left the region and is the
32
+ * writer's business.
33
+ *
34
+ * `maxEntries <= 0` returns `entries.length` - an empty window, which is the old
35
+ * behaviour ("everything printable may print") and the reason the guard is written as
36
+ * an early return rather than as a loop bound.
37
+ */
38
+ export function tailWindowStart(entries, maxEntries) {
39
+ const limit = Number.isFinite(maxEntries) ? Math.floor(maxEntries) : 0;
40
+ if (limit <= 0)
41
+ return entries.length;
42
+ let seen = 0;
43
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
44
+ if (isExcluded(entries[i]))
45
+ continue;
46
+ seen += 1;
47
+ if (seen === limit)
48
+ return i;
49
+ }
50
+ // Fewer printable entries than the window holds: the whole conversation is on screen.
51
+ return 0;
52
+ }
53
+ /** `entries.slice(start)` with the entries that never print dropped. */
54
+ export function selectTailWindow(entries, maxEntries) {
55
+ const start = tailWindowStart(entries, maxEntries);
56
+ return { start, entries: entries.slice(start).filter((entry) => !isExcluded(entry)) };
57
+ }
58
+ /**
59
+ * The print boundary: never print what the region is still showing.
60
+ *
61
+ * THIS IS THE ONLY PLACE THAT COMBINES "settled" WITH "in the window" (RV-P2-13), which
62
+ * is why the composition lives here instead of at the pump: `promote()` must keep
63
+ * receiving one number, and the two authorities it is derived from - `settledCount`
64
+ * (what may print at all) and `tailWindowStart` (what is still visible) - then cannot
65
+ * drift apart in a future edit.
66
+ *
67
+ * `settleAllEntries` is the OTHER authority, not a duplicate: it answers "what must be
68
+ * printed when the conversation is being torn down", while this answers "how far may
69
+ * this frame print".
70
+ */
71
+ export function printBoundary(entries, runActive, maxEntries) {
72
+ return Math.min(settledCount(entries, runActive), tailWindowStart(entries, maxEntries));
73
+ }
@@ -1,23 +1,33 @@
1
1
  /**
2
2
  * `useScrollbackView` — the promotion pipeline of native-scroll mode
3
- * (tui-native-terminal-scroll §4.4 / §6.2).
3
+ * (tui-native-terminal-scroll §4.4 / §6.2, tui-full-height-live-region D3/D4/D7/D8).
4
4
  *
5
5
  * `App` calls it ALWAYS (hooks cannot be conditional); `enabled: false` makes every
6
6
  * effect a no-op and keeps `scrollMode: 'app'` byte-identical to the pre-feature
7
- * build (I-S9).
7
+ * build (I-S9 / I-F10).
8
8
  *
9
9
  * What it does when enabled:
10
- * 1. Prints the session header (and the opener for an empty session) once.
11
- * 2. On every entries change, promotes each newly SETTLED entry: renders it once
12
- * through the offscreen renderer and enqueues the rows for the writer.
13
- * 3. Computes the live-region budget and the streaming preview rows for the shell.
10
+ * 1. On every entries change, promotes each entry that has LEFT THE TAIL WINDOW:
11
+ * renders it once through the offscreen renderer and enqueues the rows for the
12
+ * writer. Entries still on screen are never printed - at `H = rows - 1` a printed
13
+ * row is pushed back off the top by the next frame, and a second copy in the
14
+ * 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,
17
+ * so "on screen but not printed yet" can never become "lost" (D8 / I-F12).
18
+ *
19
+ * THE HEADER AND THE OPENER ARE NOT PRINTED HERE ANY MORE (D3/D4). They are slots of
20
+ * `ScrollbackShell` now, rendered inside the region: `H = rows - 1` means any printed
21
+ * row is immediately scrolled off the screen, so printing the banner would be the same
22
+ * as not drawing it. Their `renderNode` path, the `startedRef` latch that kept their
23
+ * element identities stable, and the `printBanner()` call in `settleAll()` are gone.
14
24
  *
15
25
  * THE PROMOTION RUNS IN A MICROTASK, NOT IN THE EFFECT. The offscreen renderer is a
16
26
  * second Ink instance, and both instances share one React reconciler. A `rerender`
17
27
  * issued from inside the main tree's commit (a layout effect) is deferred until that
18
- * commit finishes, so its frame would not exist when the effect reads it. A
19
- * microtask runs after the commit has unwound, where the offscreen render is
20
- * synchronous again. Everything the pass needs is read from refs for that reason.
28
+ * commit finishes, so its frame would not exist when the effect reads it. A microtask
29
+ * 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.
21
31
  *
22
32
  * `printedIds` is the ONLY authority on "already printed" (RV-7). The cursor is just
23
33
  * a hint for where to resume scanning: entries leave the list by retain-ring
@@ -25,7 +35,6 @@
25
35
  * cards), so an id-keyed cursor can dangle, and then the only correct answer is to
26
36
  * rescan and let `printedIds` skip.
27
37
  */
28
- import React from 'react';
29
38
  import type { Entry } from '../../agent/reducer.js';
30
39
  import type { TermCapabilities } from '../capabilities.js';
31
40
  import type { DensityMode } from '../density.js';
@@ -68,10 +77,6 @@ export interface UseScrollbackViewArgs {
68
77
  /** Terminal size. */
69
78
  rows: number;
70
79
  cols: number;
71
- /** Printed first, once. */
72
- headerNode: React.ReactNode;
73
- /** Printed after the header for an empty session. */
74
- openerNode: React.ReactNode | null;
75
80
  overlayOpen: boolean;
76
81
  draftRows: number;
77
82
  popupRows: number;
@@ -89,13 +94,16 @@ export interface ScrollbackView {
89
94
  budget: ScrollbackBudget | null;
90
95
  liveTailLines: string[];
91
96
  /**
92
- * The exact list `liveTailLines` was built from, so the real-component tail
93
- * (`LiveTail`) and the plain-text fallback can never disagree about their input.
97
+ * The entries the region is showing, in order (excluded kinds already dropped).
98
+ * `windowStart` is their index in the full list - together they are what `/perf`
99
+ * reports as `window=<start>/<n>`, the number that makes the "visible vs printable"
100
+ * gap observable instead of a guess (known limitation 5).
94
101
  */
95
- unsettledEntries: readonly Entry[];
102
+ tailEntries: readonly Entry[];
103
+ windowStart: number;
96
104
  /** Print a fresh EXPANDED copy of an entry; false when it cannot be found / rendered. */
97
105
  reprintExpanded(entryId: string): boolean;
98
- /** Promote everything printable now (shutdown). */
106
+ /** Promote everything still unprinted (shutdown) - `flushUnprinted(latest entries)`. */
99
107
  settleAll(): void;
100
108
  /** Called by `App`'s dispatch wrapper BEFORE a reset action reaches the reducer. */
101
109
  notifyReset(kind: ScrollbackResetKind): void;
@@ -1,23 +1,33 @@
1
1
  /**
2
2
  * `useScrollbackView` — the promotion pipeline of native-scroll mode
3
- * (tui-native-terminal-scroll §4.4 / §6.2).
3
+ * (tui-native-terminal-scroll §4.4 / §6.2, tui-full-height-live-region D3/D4/D7/D8).
4
4
  *
5
5
  * `App` calls it ALWAYS (hooks cannot be conditional); `enabled: false` makes every
6
6
  * effect a no-op and keeps `scrollMode: 'app'` byte-identical to the pre-feature
7
- * build (I-S9).
7
+ * build (I-S9 / I-F10).
8
8
  *
9
9
  * What it does when enabled:
10
- * 1. Prints the session header (and the opener for an empty session) once.
11
- * 2. On every entries change, promotes each newly SETTLED entry: renders it once
12
- * through the offscreen renderer and enqueues the rows for the writer.
13
- * 3. Computes the live-region budget and the streaming preview rows for the shell.
10
+ * 1. On every entries change, promotes each entry that has LEFT THE TAIL WINDOW:
11
+ * renders it once through the offscreen renderer and enqueues the rows for the
12
+ * writer. Entries still on screen are never printed - at `H = rows - 1` a printed
13
+ * row is pushed back off the top by the next frame, and a second copy in the
14
+ * 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,
17
+ * so "on screen but not printed yet" can never become "lost" (D8 / I-F12).
18
+ *
19
+ * THE HEADER AND THE OPENER ARE NOT PRINTED HERE ANY MORE (D3/D4). They are slots of
20
+ * `ScrollbackShell` now, rendered inside the region: `H = rows - 1` means any printed
21
+ * row is immediately scrolled off the screen, so printing the banner would be the same
22
+ * as not drawing it. Their `renderNode` path, the `startedRef` latch that kept their
23
+ * element identities stable, and the `printBanner()` call in `settleAll()` are gone.
14
24
  *
15
25
  * THE PROMOTION RUNS IN A MICROTASK, NOT IN THE EFFECT. The offscreen renderer is a
16
26
  * second Ink instance, and both instances share one React reconciler. A `rerender`
17
27
  * issued from inside the main tree's commit (a layout effect) is deferred until that
18
- * commit finishes, so its frame would not exist when the effect reads it. A
19
- * microtask runs after the commit has unwound, where the offscreen render is
20
- * synchronous again. Everything the pass needs is read from refs for that reason.
28
+ * commit finishes, so its frame would not exist when the effect reads it. A microtask
29
+ * 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.
21
31
  *
22
32
  * `printedIds` is the ONLY authority on "already printed" (RV-7). The cursor is just
23
33
  * a hint for where to resume scanning: entries leave the list by retain-ring
@@ -30,7 +40,8 @@ import { buildScrollbackBudget } from './budget.js';
30
40
  import { buildLivePreview } from './live-preview.js';
31
41
  import { SCROLLBACK_LIMITS } from './limits.js';
32
42
  import { createEntryRenderer } from './renderer.js';
33
- import { isExcluded, settleAllEntries, settledCount } from './settled-boundary.js';
43
+ import { isExcluded, settleAllEntries } from './settled-boundary.js';
44
+ import { printBoundary, selectTailWindow } from './tail-window.js';
34
45
  export function createScrollbackBridge(parts) {
35
46
  return {
36
47
  writer: parts.writer,
@@ -64,20 +75,15 @@ function pruneEntryIds(printed, entries) {
64
75
  printed.delete(id);
65
76
  }
66
77
  export function useScrollbackView(args) {
67
- const { enabled, bridge, entries, runActive, theme, caps, density, thinkingVisible, reducedMotion, rows, cols, headerNode, openerNode, overlayOpen, draftRows, popupRows, statusExpanded, forceInactive = false, itemCount, panelEnabled, teamRowsNatural, } = args;
78
+ const { enabled, bridge, entries, runActive, theme, caps, density, thinkingVisible, reducedMotion, rows, cols, overlayOpen, draftRows, popupRows, statusExpanded, forceInactive = false, itemCount, panelEnabled, teamRowsNatural, } = args;
68
79
  const contentCols = Math.max(1, cols - 1);
69
- const startedRef = useRef(false);
70
80
  // Everything the asynchronous pass reads travels here, refreshed on every render.
71
81
  const latest = useRef({
72
- entries, runActive, contentCols, headerNode, openerNode,
82
+ entries, runActive, contentCols,
73
83
  options: { theme, caps, density, thinkingVisible, reducedMotion },
74
84
  });
75
85
  latest.current = {
76
86
  entries, runActive, contentCols,
77
- // The banner nodes only matter until the banner is printed; afterwards keep the
78
- // old references so a changing element identity is not mistaken for new input.
79
- headerNode: startedRef.current ? latest.current.headerNode : headerNode,
80
- openerNode: startedRef.current ? latest.current.openerNode : openerNode,
81
87
  options: { theme, caps, density, thinkingVisible, reducedMotion },
82
88
  };
83
89
  const rendererRef = useRef(null);
@@ -85,10 +91,9 @@ export function useScrollbackView(args) {
85
91
  /** A resume hint only (RV-7): the id of the last entry the previous pass covered. */
86
92
  const cursorRef = useRef(null);
87
93
  const pendingReset = useRef(null);
88
- const heldTailRows = useRef(0);
89
- // Band hysteresis (release-gate-and-native-panels §6.7). Same lifecycle and same
90
- // reason as `heldTailRows`: the writer re-lays out the canvas on every batch, so a
91
- // band that shrank for two frames would make everything above the composer jump.
94
+ // Band hysteresis (release-gate-and-native-panels §6.7): the writer re-lays out the
95
+ // canvas on every batch, so a band that shrank for two frames would make everything
96
+ // above the composer jump.
92
97
  const heldRailRows = useRef(0);
93
98
  const heldTeamRows = useRef(0);
94
99
  const heldGeometry = useRef({ rows, cols });
@@ -137,25 +142,45 @@ export function useScrollbackView(args) {
137
142
  remember();
138
143
  return false;
139
144
  }, [ensureRenderer, enqueue]);
140
- const printBanner = useCallback(() => {
141
- if (startedRef.current)
145
+ /**
146
+ * THE ONE PRINT LOOP (RV-P0-2 / RV-P2-12): every not-yet-printed entry of `list`,
147
+ * oldest first, with no per-pass limit.
148
+ *
149
+ * `list` is a PARAMETER on purpose. `applyPendingReset` must pass `reset.captured` -
150
+ * the snapshot taken BEFORE the reducer reset - because by the time the microtask
151
+ * runs, `latest.current.entries` is already the NEW list and flushing that would
152
+ * print zero rows: the entries that only ever existed on screen would vanish with
153
+ * the reset. That is real data loss, not a cosmetic gap.
154
+ *
155
+ * `settleAllEntries` is the exit path's authority (everything that can ever print,
156
+ * including an entry still streaming at this instant - printed is forever, and the
157
+ * conversation is being destroyed right now, so a snapshot beats a hole). The drain
158
+ * is FIFO, so the ORDER of the calls below is the order on screen.
159
+ */
160
+ const flushUnprinted = useCallback((list) => {
161
+ if (!bridge)
142
162
  return;
143
- startedRef.current = true;
144
163
  const renderer = ensureRenderer();
145
- const { headerNode: header, openerNode: opener, contentCols: width } = latest.current;
146
- enqueue(renderer.renderNode(header, width), 'startup:header', 'header');
147
- if (opener)
148
- enqueue(renderer.renderNode(opener, width), 'startup:opener', 'opener');
149
- }, [ensureRenderer, enqueue]);
164
+ for (const entry of settleAllEntries(list)) {
165
+ if (printedIds.current.has(entry.id))
166
+ continue;
167
+ enqueue(renderer.renderEntry(entry, printableBefore(list, list.indexOf(entry)), latest.current.contentCols), entry.id, entry.kind);
168
+ printedIds.current.add(entry.id);
169
+ }
170
+ }, [bridge, ensureRenderer, enqueue]);
150
171
  const applyPendingReset = useCallback(() => {
151
172
  const reset = pendingReset.current;
152
173
  if (!reset || latest.current.entries === reset.captured)
153
174
  return;
154
175
  pendingReset.current = null;
176
+ // 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
178
+ // the whole conversation a second time.
179
+ flushUnprinted(reset.captured);
155
180
  printedIds.current = new Set();
156
181
  cursorRef.current = null;
157
182
  enqueue([reset.kind === 'clear' ? CLEARED_ROW : RESTORED_ROW], `reset:${Date.now()}`, 'notice');
158
- }, [enqueue]);
183
+ }, [enqueue, flushUnprinted]);
159
184
  const pumpRef = useRef(() => undefined);
160
185
  const schedulePump = useCallback(() => {
161
186
  if (pumpQueued.current)
@@ -168,11 +193,12 @@ export function useScrollbackView(args) {
168
193
  pumpQueued.current = false;
169
194
  if (!mountedRef.current || !bridge)
170
195
  return;
171
- printBanner();
172
196
  applyPendingReset();
173
197
  const { entries: list, runActive: active } = latest.current;
174
198
  const limit = Math.max(1, SCROLLBACK_LIMITS.feedCapacity - bridge.drain.pendingEntries() - 1);
175
- const more = promote(list, settledCount(list, active), limit);
199
+ // `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);
176
202
  pruneEntryIds(printedIds.current, list);
177
203
  if (more) {
178
204
  // Continue once the writer has consumed what is queued (a restored transcript
@@ -189,19 +215,8 @@ export function useScrollbackView(args) {
189
215
  schedulePump();
190
216
  }, [enabled, bridge, entries, runActive, schedulePump]);
191
217
  const settleAll = useCallback(() => {
192
- if (!bridge)
193
- return;
194
- const list = latest.current.entries;
195
- const renderer = ensureRenderer();
196
- printBanner();
197
- // Shutdown must not leave anything behind, so no per-pass limit.
198
- for (const entry of settleAllEntries(list)) {
199
- if (printedIds.current.has(entry.id))
200
- continue;
201
- enqueue(renderer.renderEntry(entry, printableBefore(list, list.indexOf(entry)), latest.current.contentCols), entry.id, entry.kind);
202
- printedIds.current.add(entry.id);
203
- }
204
- }, [bridge, ensureRenderer, printBanner, enqueue]);
218
+ flushUnprinted(latest.current.entries);
219
+ }, [flushUnprinted]);
205
220
  // Shutdown hook for the exit paths. `restore()` runs it AFTER the tree is gone
206
221
  // (normal exit) or while it is alive (signal), so it renders synchronously and then
207
222
  // releases the offscreen instance. The unmount cleanup deliberately renders nothing:
@@ -223,16 +238,14 @@ export function useScrollbackView(args) {
223
238
  };
224
239
  return () => { mountedRef.current = false; };
225
240
  }, [enabled, bridge]);
226
- // --- Live region budget and streaming preview. ---------------------------
227
- const boundary = useMemo(() => settledCount(entries, runActive), [entries, runActive]);
228
- const unsettled = useMemo(() => entries.slice(boundary).filter((entry) => !isExcluded(entry)), [entries, boundary]);
229
- const wanted = enabled
230
- ? buildLivePreview({
231
- unsettled, cols: contentCols, rows: SCROLLBACK_LIMITS.maxLiveTailRows, thinkingVisible,
232
- }).length
233
- : 0;
234
- // D11 · the preview height only grows while anything is unsettled, then resets.
235
- heldTailRows.current = unsettled.length === 0 ? 0 : Math.max(heldTailRows.current, wanted);
241
+ // --- Live region budget and the tail window. ------------------------------
242
+ // The window is the last K printable entries, settled or not (D6/D7). `slice` +
243
+ // `tailWindowStart` is `tail-window.ts`'s definition, and `printBoundary` uses the
244
+ // same start - so "what is on screen" and "what is withheld from the writer" cannot
245
+ // disagree about where the boundary is.
246
+ const window = useMemo(() => selectTailWindow(entries, SCROLLBACK_LIMITS.liveTailMaxEntries), [entries]);
247
+ const tailEntries = window.entries;
248
+ const windowStart = window.start;
236
249
  // The band's hysteresis has exactly THREE reset points and no others: the plan
237
250
  // emptied, the dispatch finished, or the geometry changed (a new size is a new
238
251
  // measurement - the same treatment `App` gives `guardTripped`).
@@ -248,29 +261,32 @@ export function useScrollbackView(args) {
248
261
  const budget = useMemo(() => enabled
249
262
  ? buildScrollbackBudget({
250
263
  rows, cols, draftRows, popupRows, statusExpanded, overlayOpen,
251
- liveTailWanted: heldTailRows.current, forceInactive,
264
+ forceInactive,
252
265
  band: {
253
266
  itemCount, panelEnabled, teamRowsNatural,
254
267
  heldRailRows: heldRailRows.current, heldTeamRows: heldTeamRows.current,
255
268
  },
256
269
  })
257
270
  : null,
258
- // `heldTailRows.current` and the two band refs are read at memo time; `wanted`,
259
- // `itemCount` and `teamRowsNatural` are what move them. ONE memo, deliberately:
260
- // a second one depending on the first would render the previous frame's band.
271
+ // The two band refs are read at memo time; `itemCount` and `teamRowsNatural` are
272
+ // what move them. The budget no longer depends on CONTENT at all (the band fills the
273
+ // pool, D5), so nothing about the transcript belongs in this dependency list.
261
274
  // eslint-disable-next-line react-hooks/exhaustive-deps
262
- [enabled, rows, cols, draftRows, popupRows, statusExpanded, overlayOpen, wanted,
263
- unsettled.length, forceInactive, itemCount, panelEnabled, teamRowsNatural]);
275
+ [enabled, rows, cols, draftRows, popupRows, statusExpanded, overlayOpen,
276
+ forceInactive, itemCount, panelEnabled, teamRowsNatural]);
264
277
  if (budget) {
265
278
  // Monotone memory, fed back into the memo above on the next frame.
266
279
  heldRailRows.current = budget.band.heldRailRows;
267
280
  heldTeamRows.current = budget.band.heldTeamRows;
268
281
  }
269
- // Print keeps `contentCols`; only the live preview gives up the rail's columns (I-5).
282
+ // Print keeps `contentCols`; only the live tail gives up the rail's columns (I-5).
270
283
  const previewCols = budget ? budget.previewCols : contentCols;
271
284
  const liveTailLines = useMemo(() => enabled && budget && budget.liveTailRows > 0
272
- ? buildLivePreview({ unsettled, cols: previewCols, rows: budget.liveTailRows, thinkingVisible })
273
- : [], [enabled, budget, unsettled, previewCols, thinkingVisible]);
285
+ ? buildLivePreview({
286
+ unsettled: tailEntries, cols: previewCols, rows: budget.liveTailRows,
287
+ thinkingVisible,
288
+ })
289
+ : [], [enabled, budget, tailEntries, previewCols, thinkingVisible]);
274
290
  const reprintExpanded = useCallback((entryId) => {
275
291
  if (!enabled || !bridge)
276
292
  return false;
@@ -288,7 +304,11 @@ export function useScrollbackView(args) {
288
304
  const notifyReset = useCallback((kind) => {
289
305
  if (!enabled)
290
306
  return;
307
+ // The PRE-reset snapshot: see `flushUnprinted`'s note on why this cannot be read
308
+ // from `latest` when the microtask finally runs.
291
309
  pendingReset.current = { kind, captured: latest.current.entries };
292
310
  }, [enabled]);
293
- return { budget, liveTailLines, unsettledEntries: unsettled, reprintExpanded, settleAll, notifyReset };
311
+ return {
312
+ budget, liveTailLines, tailEntries, windowStart, reprintExpanded, settleAll, notifyReset,
313
+ };
294
314
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.32",
3
+ "version": "0.6.33",
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.44",
59
+ "@aragon-agent/core": "^0.2.45",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",