@aragon-agent/cli 0.6.32 → 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.
- package/CHANGELOG.md +2305 -2223
- package/README.md +3007 -2957
- package/dist/cli.js +4 -0
- package/dist/commands/perf.d.ts +34 -1
- package/dist/commands/perf.js +11 -3
- package/dist/ui/App.js +64 -12
- package/dist/ui/ScrollbackShell.d.ts +48 -13
- package/dist/ui/ScrollbackShell.js +7 -3
- package/dist/ui/scrollback/LiveTail.d.ts +64 -15
- package/dist/ui/scrollback/LiveTail.js +27 -11
- package/dist/ui/scrollback/band.d.ts +33 -24
- package/dist/ui/scrollback/band.js +43 -22
- package/dist/ui/scrollback/budget.d.ts +31 -19
- package/dist/ui/scrollback/budget.js +37 -25
- package/dist/ui/scrollback/limits.d.ts +34 -10
- package/dist/ui/scrollback/limits.js +34 -10
- package/dist/ui/scrollback/live-preview.d.ts +18 -10
- package/dist/ui/scrollback/live-preview.js +14 -11
- package/dist/ui/scrollback/overflow-print.d.ts +108 -0
- package/dist/ui/scrollback/overflow-print.js +120 -0
- package/dist/ui/scrollback/renderer.d.ts +11 -1
- package/dist/ui/scrollback/renderer.js +53 -25
- package/dist/ui/scrollback/tail-anchor.d.ts +68 -0
- package/dist/ui/scrollback/tail-anchor.js +77 -0
- package/dist/ui/scrollback/tail-window.d.ts +61 -0
- package/dist/ui/scrollback/tail-window.js +74 -0
- package/dist/ui/scrollback/use-scrollback-view.d.ts +60 -24
- package/dist/ui/scrollback/use-scrollback-view.js +434 -134
- package/package.json +2 -2
|
@@ -1,36 +1,65 @@
|
|
|
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
|
+
* 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
|
|
7
|
-
* build (I-S9).
|
|
8
|
+
* build (I-S9 / I-F10).
|
|
8
9
|
*
|
|
9
10
|
* What it does when enabled:
|
|
10
|
-
* 1.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 1. On every entries change, promotes each entry that has LEFT THE TAIL WINDOW:
|
|
12
|
+
* renders it once through the offscreen renderer and enqueues the rows for the
|
|
13
|
+
* writer. Entries still on screen are never printed - at `H = rows - 1` a printed
|
|
14
|
+
* row is pushed back off the top by the next frame, and a second copy in the
|
|
15
|
+
* scrollback would be a duplicate the user meets by scrolling up (I-F8).
|
|
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,
|
|
25
|
+
* so "on screen but not printed yet" can never become "lost" (D8 / I-F12).
|
|
26
|
+
*
|
|
27
|
+
* THE HEADER AND THE OPENER ARE NOT PRINTED HERE ANY MORE (D3/D4). They are slots of
|
|
28
|
+
* `ScrollbackShell` now, rendered inside the region: `H = rows - 1` means any printed
|
|
29
|
+
* row is immediately scrolled off the screen, so printing the banner would be the same
|
|
30
|
+
* as not drawing it. Their `renderNode` path, the `startedRef` latch that kept their
|
|
31
|
+
* element identities stable, and the `printBanner()` call in `settleAll()` are gone.
|
|
14
32
|
*
|
|
15
33
|
* THE PROMOTION RUNS IN A MICROTASK, NOT IN THE EFFECT. The offscreen renderer is a
|
|
16
34
|
* second Ink instance, and both instances share one React reconciler. A `rerender`
|
|
17
35
|
* 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
|
-
*
|
|
20
|
-
*
|
|
36
|
+
* commit finishes, so its frame would not exist when the effect reads it. A microtask
|
|
37
|
+
* runs after the commit has unwound, where the offscreen render is synchronous again.
|
|
38
|
+
*
|
|
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.
|
|
21
45
|
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* rescan and let
|
|
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.
|
|
27
52
|
*/
|
|
28
|
-
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';
|
|
29
55
|
import { buildScrollbackBudget } from './budget.js';
|
|
30
56
|
import { buildLivePreview } from './live-preview.js';
|
|
31
57
|
import { SCROLLBACK_LIMITS } from './limits.js';
|
|
58
|
+
import { capPrintedRows, planOverflowPrint, reflowSeamRow, } from './overflow-print.js';
|
|
32
59
|
import { createEntryRenderer } from './renderer.js';
|
|
33
|
-
import { isExcluded, settleAllEntries
|
|
60
|
+
import { isEntryStable, isExcluded, settleAllEntries } from './settled-boundary.js';
|
|
61
|
+
import { resolveTailAnchor } from './tail-anchor.js';
|
|
62
|
+
import { printBoundary, selectTailWindow } from './tail-window.js';
|
|
34
63
|
export function createScrollbackBridge(parts) {
|
|
35
64
|
return {
|
|
36
65
|
writer: parts.writer,
|
|
@@ -52,48 +81,68 @@ function printableBefore(entries, index) {
|
|
|
52
81
|
}
|
|
53
82
|
/**
|
|
54
83
|
* Ids leave `entries` by retain-ring trimming and never come back (ids are unique
|
|
55
|
-
* per session), so
|
|
84
|
+
* per session), so both row maps are trimmed to the live list once they grow well past
|
|
56
85
|
* it. Only ids still present can be re-evaluated, so dropping the rest is lossless.
|
|
57
86
|
*/
|
|
58
|
-
function pruneEntryIds(printed,
|
|
59
|
-
|
|
87
|
+
function pruneEntryIds(entries, printed, heights) {
|
|
88
|
+
const bound = Math.max(2000, entries.length * 3);
|
|
89
|
+
if (printed.size <= bound && heights.size <= bound)
|
|
60
90
|
return;
|
|
61
91
|
const live = new Set(entries.map((entry) => entry.id));
|
|
62
|
-
for (const id of printed)
|
|
92
|
+
for (const id of [...printed.keys()])
|
|
63
93
|
if (!live.has(id))
|
|
64
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;
|
|
65
110
|
}
|
|
66
111
|
export function useScrollbackView(args) {
|
|
67
|
-
const { enabled, bridge, entries, runActive, theme, caps, density, thinkingVisible, reducedMotion, rows, cols,
|
|
112
|
+
const { enabled, bridge, entries, runActive, theme, caps, density, thinkingVisible, reducedMotion, rows, cols, overlayOpen, draftRows, popupRows, statusExpanded, forceInactive = false, itemCount, panelEnabled, teamRowsNatural, expandedToolIds, } = args;
|
|
68
113
|
const contentCols = Math.max(1, cols - 1);
|
|
69
|
-
|
|
70
|
-
//
|
|
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).
|
|
71
116
|
const latest = useRef({
|
|
72
|
-
entries, runActive, contentCols,
|
|
117
|
+
entries, runActive, contentCols, previewCols: contentCols, expandedToolIds,
|
|
73
118
|
options: { theme, caps, density, thinkingVisible, reducedMotion },
|
|
74
119
|
});
|
|
75
120
|
latest.current = {
|
|
76
|
-
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,
|
|
121
|
+
entries, runActive, contentCols, previewCols: latest.current.previewCols, expandedToolIds,
|
|
81
122
|
options: { theme, caps, density, thinkingVisible, reducedMotion },
|
|
82
123
|
};
|
|
83
124
|
const rendererRef = useRef(null);
|
|
84
|
-
|
|
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());
|
|
85
129
|
/** A resume hint only (RV-7): the id of the last entry the previous pass covered. */
|
|
86
130
|
const cursorRef = useRef(null);
|
|
87
131
|
const pendingReset = useRef(null);
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
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);
|
|
135
|
+
// Band hysteresis (release-gate-and-native-panels §6.7): the writer re-lays out the
|
|
136
|
+
// canvas on every batch, so a band that shrank for two frames would make everything
|
|
137
|
+
// above the composer jump.
|
|
92
138
|
const heldRailRows = useRef(0);
|
|
93
139
|
const heldTeamRows = useRef(0);
|
|
94
140
|
const heldGeometry = useRef({ rows, cols });
|
|
95
141
|
const pumpQueued = useRef(false);
|
|
96
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);
|
|
97
146
|
const ensureRenderer = useCallback(() => {
|
|
98
147
|
if (!rendererRef.current)
|
|
99
148
|
rendererRef.current = createEntryRenderer(latest.current.options);
|
|
@@ -105,57 +154,163 @@ export function useScrollbackView(args) {
|
|
|
105
154
|
return;
|
|
106
155
|
bridge.drain.enqueue(rowsToPrint, { id, kind });
|
|
107
156
|
}, [bridge]);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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);
|
|
111
247
|
let start = 0;
|
|
112
248
|
const cursor = cursorRef.current;
|
|
113
249
|
if (cursor && list[cursor.index]?.id === cursor.id)
|
|
114
250
|
start = cursor.index + 1;
|
|
115
|
-
let
|
|
251
|
+
let used = 0;
|
|
116
252
|
let lastIndex = start - 1;
|
|
117
253
|
const remember = () => {
|
|
118
254
|
cursorRef.current = lastIndex >= 0 ? { id: list[lastIndex].id, index: lastIndex } : null;
|
|
119
255
|
};
|
|
120
256
|
for (let i = start; i < end; i += 1) {
|
|
121
257
|
const entry = list[i];
|
|
122
|
-
|
|
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) {
|
|
123
261
|
lastIndex = i;
|
|
124
262
|
continue;
|
|
125
263
|
}
|
|
126
|
-
if (
|
|
264
|
+
if (used >= limit) {
|
|
127
265
|
remember();
|
|
128
|
-
return true;
|
|
266
|
+
return { limited: true, used };
|
|
129
267
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// A rendered-to-nothing entry is registered too, or it would be retried forever.
|
|
133
|
-
printedIds.current.add(entry.id);
|
|
134
|
-
promoted += 1;
|
|
268
|
+
printRemainder(list, i, frame.contentCols, known, expanded);
|
|
269
|
+
used += 1;
|
|
135
270
|
lastIndex = i;
|
|
136
271
|
}
|
|
137
272
|
remember();
|
|
138
|
-
return false;
|
|
139
|
-
}, [
|
|
140
|
-
|
|
141
|
-
|
|
273
|
+
return { limited: false, used };
|
|
274
|
+
}, [expandedIdsOf, printRemainder]);
|
|
275
|
+
/**
|
|
276
|
+
* THE ONE PRINT LOOP (RV-P0-2 / RV-P2-12): every not-yet-printed entry of `list`,
|
|
277
|
+
* oldest first, with no per-pass limit.
|
|
278
|
+
*
|
|
279
|
+
* `list` is a PARAMETER on purpose. `applyPendingReset` must pass `reset.captured` -
|
|
280
|
+
* the snapshot taken BEFORE the reducer reset - because by the time the microtask
|
|
281
|
+
* runs, `latest.current.entries` is already the NEW list and flushing that would
|
|
282
|
+
* print zero rows: the entries that only ever existed on screen would vanish with
|
|
283
|
+
* the reset. That is real data loss, not a cosmetic gap.
|
|
284
|
+
*
|
|
285
|
+
* `settleAllEntries` is the exit path's authority (everything that can ever print,
|
|
286
|
+
* including an entry still streaming at this instant - printed is forever, and the
|
|
287
|
+
* conversation is being destroyed right now, so a snapshot beats a hole). The drain
|
|
288
|
+
* is FIFO, so the ORDER of the calls below is the order on screen.
|
|
289
|
+
*/
|
|
290
|
+
const flushUnprinted = useCallback((list) => {
|
|
291
|
+
if (!bridge)
|
|
142
292
|
return;
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
293
|
+
const known = [latest.current.contentCols, latest.current.previewCols];
|
|
294
|
+
const expanded = expandedIdsOf(latest.current.expandedToolIds);
|
|
295
|
+
for (const entry of settleAllEntries(list)) {
|
|
296
|
+
if (printed.current.get(entry.id)?.done === true)
|
|
297
|
+
continue;
|
|
298
|
+
printRemainder(list, list.indexOf(entry), latest.current.contentCols, known, expanded);
|
|
299
|
+
}
|
|
300
|
+
}, [bridge, expandedIdsOf, printRemainder]);
|
|
150
301
|
const applyPendingReset = useCallback(() => {
|
|
151
302
|
const reset = pendingReset.current;
|
|
152
303
|
if (!reset || latest.current.entries === reset.captured)
|
|
153
304
|
return;
|
|
154
305
|
pendingReset.current = null;
|
|
155
|
-
|
|
306
|
+
// ORDER IS THE CONTRACT (AC-F16): flush the OLD list, then the separator, then
|
|
307
|
+
// forget what was printed. Clearing `printed` first would make the flush print
|
|
308
|
+
// the whole conversation a second time.
|
|
309
|
+
flushUnprinted(reset.captured);
|
|
310
|
+
printed.current = new Map();
|
|
156
311
|
cursorRef.current = null;
|
|
157
312
|
enqueue([reset.kind === 'clear' ? CLEARED_ROW : RESTORED_ROW], `reset:${Date.now()}`, 'notice');
|
|
158
|
-
}, [enqueue]);
|
|
313
|
+
}, [enqueue, flushUnprinted]);
|
|
159
314
|
const pumpRef = useRef(() => undefined);
|
|
160
315
|
const schedulePump = useCallback(() => {
|
|
161
316
|
if (pumpQueued.current)
|
|
@@ -163,76 +318,117 @@ export function useScrollbackView(args) {
|
|
|
163
318
|
pumpQueued.current = true;
|
|
164
319
|
queueMicrotask(() => pumpRef.current());
|
|
165
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]);
|
|
166
400
|
/** The promotion pass. Runs in a microtask (see the file header). */
|
|
167
401
|
pumpRef.current = () => {
|
|
168
402
|
pumpQueued.current = false;
|
|
169
|
-
|
|
403
|
+
const frame = committed.current;
|
|
404
|
+
if (!mountedRef.current || !bridge || !frame)
|
|
170
405
|
return;
|
|
171
|
-
printBanner();
|
|
172
406
|
applyPendingReset();
|
|
173
|
-
const
|
|
407
|
+
const known = [frame.contentCols, frame.previewCols];
|
|
174
408
|
const limit = Math.max(1, SCROLLBACK_LIMITS.feedCapacity - bridge.drain.pendingEntries() - 1);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
409
|
+
// `printBoundary` = min(settled, window start): the region keeps what it shows.
|
|
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) {
|
|
178
414
|
// Continue once the writer has consumed what is queued (a restored transcript
|
|
179
415
|
// longer than the feed is promoted in waves rather than folded into a marker).
|
|
180
|
-
|
|
181
|
-
bridge.drain.onDrained = null;
|
|
182
|
-
schedulePump();
|
|
183
|
-
};
|
|
184
|
-
bridge.requestFlush();
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
useLayoutEffect(() => {
|
|
188
|
-
if (enabled && bridge)
|
|
189
|
-
schedulePump();
|
|
190
|
-
}, [enabled, bridge, entries, runActive, schedulePump]);
|
|
191
|
-
const settleAll = useCallback(() => {
|
|
192
|
-
if (!bridge)
|
|
416
|
+
continueOnDrain();
|
|
193
417
|
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
418
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
settleAllRef.current();
|
|
218
|
-
}
|
|
219
|
-
finally {
|
|
220
|
-
rendererRef.current?.dispose();
|
|
221
|
-
rendererRef.current = null;
|
|
222
|
-
}
|
|
223
|
-
};
|
|
224
|
-
return () => { mountedRef.current = false; };
|
|
225
|
-
}, [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);
|
|
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();
|
|
423
|
+
};
|
|
424
|
+
// --- Live region budget and the tail window. ------------------------------
|
|
425
|
+
// The window is the last K printable entries, settled or not (D6/D7). `slice` +
|
|
426
|
+
// `tailWindowStart` is `tail-window.ts`'s definition, and `printBoundary` uses the
|
|
427
|
+
// same start - so "what is on screen" and "what is withheld from the writer" cannot
|
|
428
|
+
// disagree about where the boundary is.
|
|
429
|
+
const window = useMemo(() => selectTailWindow(entries, SCROLLBACK_LIMITS.liveTailMaxEntries), [entries]);
|
|
430
|
+
const tailEntries = window.entries;
|
|
431
|
+
const windowStart = window.start;
|
|
236
432
|
// The band's hysteresis has exactly THREE reset points and no others: the plan
|
|
237
433
|
// emptied, the dispatch finished, or the geometry changed (a new size is a new
|
|
238
434
|
// measurement - the same treatment `App` gives `guardTripped`).
|
|
@@ -248,29 +444,108 @@ export function useScrollbackView(args) {
|
|
|
248
444
|
const budget = useMemo(() => enabled
|
|
249
445
|
? buildScrollbackBudget({
|
|
250
446
|
rows, cols, draftRows, popupRows, statusExpanded, overlayOpen,
|
|
251
|
-
|
|
447
|
+
forceInactive,
|
|
252
448
|
band: {
|
|
253
449
|
itemCount, panelEnabled, teamRowsNatural,
|
|
254
450
|
heldRailRows: heldRailRows.current, heldTeamRows: heldTeamRows.current,
|
|
255
451
|
},
|
|
256
452
|
})
|
|
257
453
|
: null,
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
454
|
+
// The two band refs are read at memo time; `itemCount` and `teamRowsNatural` are
|
|
455
|
+
// what move them. The budget no longer depends on CONTENT at all (the band fills the
|
|
456
|
+
// pool, D5), so nothing about the transcript belongs in this dependency list.
|
|
261
457
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
262
|
-
[enabled, rows, cols, draftRows, popupRows, statusExpanded, overlayOpen,
|
|
263
|
-
|
|
458
|
+
[enabled, rows, cols, draftRows, popupRows, statusExpanded, overlayOpen,
|
|
459
|
+
forceInactive, itemCount, panelEnabled, teamRowsNatural]);
|
|
264
460
|
if (budget) {
|
|
265
461
|
// Monotone memory, fed back into the memo above on the next frame.
|
|
266
462
|
heldRailRows.current = budget.band.heldRailRows;
|
|
267
463
|
heldTeamRows.current = budget.band.heldTeamRows;
|
|
268
464
|
}
|
|
269
|
-
// Print keeps `contentCols`; only the live
|
|
465
|
+
// Print keeps `contentCols`; only the live tail gives up the rail's columns (I-5).
|
|
270
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
|
+
});
|
|
271
504
|
const liveTailLines = useMemo(() => enabled && budget && budget.liveTailRows > 0
|
|
272
|
-
? buildLivePreview({
|
|
273
|
-
|
|
505
|
+
? buildLivePreview({
|
|
506
|
+
unsettled: tailEntries, cols: previewCols, rows: budget.liveTailRows,
|
|
507
|
+
thinkingVisible,
|
|
508
|
+
})
|
|
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]);
|
|
274
549
|
const reprintExpanded = useCallback((entryId) => {
|
|
275
550
|
if (!enabled || !bridge)
|
|
276
551
|
return false;
|
|
@@ -279,16 +554,41 @@ export function useScrollbackView(args) {
|
|
|
279
554
|
if (index < 0)
|
|
280
555
|
return false;
|
|
281
556
|
const entry = list[index];
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
+
});
|
|
286
578
|
return true;
|
|
287
|
-
}, [enabled,
|
|
579
|
+
}, [bridge, enabled, enqueue, ensureRenderer]);
|
|
288
580
|
const notifyReset = useCallback((kind) => {
|
|
289
581
|
if (!enabled)
|
|
290
582
|
return;
|
|
583
|
+
// The PRE-reset snapshot: see `flushUnprinted`'s note on why this cannot be read
|
|
584
|
+
// from `latest` when the microtask finally runs.
|
|
291
585
|
pendingReset.current = { kind, captured: latest.current.entries };
|
|
292
586
|
}, [enabled]);
|
|
293
|
-
return {
|
|
587
|
+
return {
|
|
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,
|
|
593
|
+
};
|
|
294
594
|
}
|