@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,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (tui-native-terminal-scroll §4.6).
|
|
2
|
+
* Plain-text fallback rows for the native-scroll live region
|
|
3
|
+
* (tui-native-terminal-scroll §4.6, tui-full-height-live-region RV-P2-7).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* THE REAL TAIL IS `LiveTail`, NOT THIS. Native mode draws the region's tail with the
|
|
6
|
+
* actual entry components, so these rows are only the fallback for a frame that mounts
|
|
7
|
+
* no component there (and the floor of the measurement path). They are kept rather than
|
|
8
|
+
* deleted because the render tests use them to assert exactly that frame, and because
|
|
9
|
+
* `<= 24 wrapToRows` calls per frame is far below the cost of the components it stands
|
|
10
|
+
* in for.
|
|
11
|
+
*
|
|
12
|
+
* The input is the TAIL WINDOW (`selectTailWindow`), settled entries included: at
|
|
13
|
+
* `H = rows - 1` the region shows the last K entries of the conversation, so a
|
|
14
|
+
* projection built only from the unfinished ones would disagree with the screen.
|
|
15
|
+
* The field keeps its historical name `unsettled` because it is still "the entries the
|
|
16
|
+
* region is showing", and renaming it would touch the preview tests for no behaviour.
|
|
10
17
|
*
|
|
11
18
|
* PURE: no Ink, no theme, no I/O. Every returned row fits `cols` display columns.
|
|
12
19
|
*/
|
|
@@ -100,7 +107,3 @@ export function buildLivePreview(input) {
|
|
|
100
107
|
}
|
|
101
108
|
return all.slice(-rows);
|
|
102
109
|
}
|
|
103
|
-
/** How many preview rows the unsettled entries would like, before budget clamping. */
|
|
104
|
-
export function wantedPreviewRows(input, cap) {
|
|
105
|
-
return buildLivePreview({ ...input, rows: cap }).length;
|
|
106
|
-
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOP GIVE-WAY - the row-level print plan of the native-scroll live region
|
|
3
|
+
* (tui-final-reply-truncated-unscrollable, plan A-prime / B3).
|
|
4
|
+
*
|
|
5
|
+
* THE DEFECT THIS ANSWERS. The region is the whole screen (`H = rows - 1`), so the box
|
|
6
|
+
* that holds the conversation is only `previewRows` tall (24 rows on a 30-row terminal),
|
|
7
|
+
* while the boundary that decides what may print into the terminal's scrollback is
|
|
8
|
+
* counted in ENTRIES (`liveTailMaxEntries`). A settled answer taller than the box is
|
|
9
|
+
* therefore inside the window, never printed, and clipped from the top by
|
|
10
|
+
* `justifyContent="flex-end"`. Native mode has no in-frame viewport - the wheel scrolls
|
|
11
|
+
* the terminal's scrollback - so the clipped rows exist nowhere the user can reach.
|
|
12
|
+
*
|
|
13
|
+
* The region cannot simply grow: a frame as tall as the terminal makes Ink write
|
|
14
|
+
* `clearTerminal` (which contains `ESC[3J`) and that erases the user's scrollback. So the
|
|
15
|
+
* only direction that keeps both promises is this one: the box keeps showing what it can,
|
|
16
|
+
* and the rows it hides are PRINTED into the scrollback, where the terminal's own scroll
|
|
17
|
+
* already reaches. Nothing on screen changes; the head of a long answer simply becomes
|
|
18
|
+
* readable one wheel turn up.
|
|
19
|
+
*
|
|
20
|
+
* WHAT THE PLAN GUARANTEES (the invariants the implementation batches are verified by):
|
|
21
|
+
* I-B1 `printed(e) + visible(e) = rows(e)` and the two ranges are adjacent and disjoint,
|
|
22
|
+
* so `scrollback + screen` covers every row exactly once (no gap, no duplicate).
|
|
23
|
+
* I-B2 Only STABLE entries may print, and the FIRST unstable entry is a wall: nothing at
|
|
24
|
+
* or after it is ever planned, even when a stable entry follows it (a `notice` after
|
|
25
|
+
* a streaming answer is stable, and printing it here would put rows into the
|
|
26
|
+
* scrollback out of conversation order, which is unrecoverable).
|
|
27
|
+
* I-B3 The plan is ordered oldest-first and carries no gaps: the caller prints the
|
|
28
|
+
* segments in the order given, so the scrollback keeps conversation order.
|
|
29
|
+
* I-B4 It never plans a row that is still on screen: `hiddenRows` is measured against the
|
|
30
|
+
* box height, and the box clips from the TOP only while it is bottom-anchored
|
|
31
|
+
* (`tail-anchor.ts`), which the caller checks before printing anything.
|
|
32
|
+
*
|
|
33
|
+
* PURE: no Ink, no I/O, no clock, no `node:*`. That is why the caller injects the measured
|
|
34
|
+
* heights and the printed counts instead of this module measuring anything itself: the same
|
|
35
|
+
* arithmetic then drives a unit test with an arbitrary table instead of a screen.
|
|
36
|
+
*/
|
|
37
|
+
import type { Entry } from '../../agent/reducer.js';
|
|
38
|
+
/** One entry of the tail window, as the planner needs it. */
|
|
39
|
+
export interface OverflowWindowEntry {
|
|
40
|
+
entry: Entry;
|
|
41
|
+
/**
|
|
42
|
+
* Rows this entry occupies in the live region, measured at the SAME width the region
|
|
43
|
+
* draws it at (`previewCols`). An estimate is not good enough here: the plan decides how
|
|
44
|
+
* many rows are hidden, and a wrong count either duplicates a visible row or leaves one
|
|
45
|
+
* unreachable.
|
|
46
|
+
*/
|
|
47
|
+
rows: number;
|
|
48
|
+
/** Leading rows of this entry already in the scrollback, `0` when none. */
|
|
49
|
+
printed: number;
|
|
50
|
+
/**
|
|
51
|
+
* `isEntryStable(entry, runActive, isTail)` for this entry - the caller's answer, because
|
|
52
|
+
* "is this the tail" is a property of the FULL list and the window is a filtered slice.
|
|
53
|
+
*/
|
|
54
|
+
stable: boolean;
|
|
55
|
+
}
|
|
56
|
+
/** One run of rows to print: `[fromRow, toRow)` of `entry`'s own rows. */
|
|
57
|
+
export interface OverflowSegment {
|
|
58
|
+
/** Index into the window the planner was given. */
|
|
59
|
+
index: number;
|
|
60
|
+
id: string;
|
|
61
|
+
/** First row to print, 0-based from the top of the entry. */
|
|
62
|
+
fromRow: number;
|
|
63
|
+
/** One past the last row to print. */
|
|
64
|
+
toRow: number;
|
|
65
|
+
}
|
|
66
|
+
export interface OverflowPlan {
|
|
67
|
+
/** Rows to hand to the writer, oldest entry first (I-B3). */
|
|
68
|
+
segments: readonly OverflowSegment[];
|
|
69
|
+
/** Rows the box hides above its top edge; `0` when the window fits. */
|
|
70
|
+
hiddenRows: number;
|
|
71
|
+
/** Measured height of the whole window, including entries past the wall. */
|
|
72
|
+
totalRows: number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Which rows of the window the box is hiding, and how many of them are not printed yet.
|
|
76
|
+
*
|
|
77
|
+
* @param window The tail window, OLDEST FIRST, excluded entries already dropped.
|
|
78
|
+
* @param budgetRows Height of the box that shows the window (`band.previewRows`).
|
|
79
|
+
* @returns The print plan; `segments` is empty when nothing is hidden or when the first
|
|
80
|
+
* unstable entry sits at the top of the window (see I-B2).
|
|
81
|
+
*/
|
|
82
|
+
export declare function planOverflowPrint(window: readonly OverflowWindowEntry[], budgetRows: number): OverflowPlan;
|
|
83
|
+
/**
|
|
84
|
+
* The row that marks a WIDTH CHANGE inside one entry's printed history.
|
|
85
|
+
*
|
|
86
|
+
* "Rows already printed" is a width-relative quantity: a line that took two rows at 80
|
|
87
|
+
* columns takes one at 120. Continuing to slice at the old index would skip content
|
|
88
|
+
* (`W1` breaks) or repeat it silently. So a width change prints this seam and restarts the
|
|
89
|
+
* entry at the new width: every row stays reachable, and the only cost is one bounded
|
|
90
|
+
* repetition of the rows above the seam (documented in the README).
|
|
91
|
+
*/
|
|
92
|
+
export declare function reflowSeamRow(printedRows: number, printedCols: number, cols: number): string;
|
|
93
|
+
/** The single wording of the per-entry row cap, shared by the renderer and the pipeline. */
|
|
94
|
+
export declare function elisionRow(hidden: number): string;
|
|
95
|
+
/**
|
|
96
|
+
* Apply `maxRowsPerEntry` to the REMAINDER of one entry.
|
|
97
|
+
*
|
|
98
|
+
* The cap used to be a single "keep the first 399 rows, then say how many were dropped".
|
|
99
|
+
* That is still what a never-printed entry gets (`headRows === 0`), because it keeps the
|
|
100
|
+
* part of the answer the user has already scrolled past. An entry whose HEAD is already in
|
|
101
|
+
* the scrollback gets the opposite treatment on purpose: the notice goes FIRST and the LAST
|
|
102
|
+
* rows follow, because the tail is the part the reader is looking at on screen, and eliding
|
|
103
|
+
* it would delete the answer they just finished reading from their history.
|
|
104
|
+
*
|
|
105
|
+
* @param tail Rows still to print (`body.slice(fromRow)`).
|
|
106
|
+
* @param headRows Rows of this entry already printed.
|
|
107
|
+
*/
|
|
108
|
+
export declare function capPrintedRows(tail: readonly string[], headRows: number): readonly string[];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOP GIVE-WAY - the row-level print plan of the native-scroll live region
|
|
3
|
+
* (tui-final-reply-truncated-unscrollable, plan A-prime / B3).
|
|
4
|
+
*
|
|
5
|
+
* THE DEFECT THIS ANSWERS. The region is the whole screen (`H = rows - 1`), so the box
|
|
6
|
+
* that holds the conversation is only `previewRows` tall (24 rows on a 30-row terminal),
|
|
7
|
+
* while the boundary that decides what may print into the terminal's scrollback is
|
|
8
|
+
* counted in ENTRIES (`liveTailMaxEntries`). A settled answer taller than the box is
|
|
9
|
+
* therefore inside the window, never printed, and clipped from the top by
|
|
10
|
+
* `justifyContent="flex-end"`. Native mode has no in-frame viewport - the wheel scrolls
|
|
11
|
+
* the terminal's scrollback - so the clipped rows exist nowhere the user can reach.
|
|
12
|
+
*
|
|
13
|
+
* The region cannot simply grow: a frame as tall as the terminal makes Ink write
|
|
14
|
+
* `clearTerminal` (which contains `ESC[3J`) and that erases the user's scrollback. So the
|
|
15
|
+
* only direction that keeps both promises is this one: the box keeps showing what it can,
|
|
16
|
+
* and the rows it hides are PRINTED into the scrollback, where the terminal's own scroll
|
|
17
|
+
* already reaches. Nothing on screen changes; the head of a long answer simply becomes
|
|
18
|
+
* readable one wheel turn up.
|
|
19
|
+
*
|
|
20
|
+
* WHAT THE PLAN GUARANTEES (the invariants the implementation batches are verified by):
|
|
21
|
+
* I-B1 `printed(e) + visible(e) = rows(e)` and the two ranges are adjacent and disjoint,
|
|
22
|
+
* so `scrollback + screen` covers every row exactly once (no gap, no duplicate).
|
|
23
|
+
* I-B2 Only STABLE entries may print, and the FIRST unstable entry is a wall: nothing at
|
|
24
|
+
* or after it is ever planned, even when a stable entry follows it (a `notice` after
|
|
25
|
+
* a streaming answer is stable, and printing it here would put rows into the
|
|
26
|
+
* scrollback out of conversation order, which is unrecoverable).
|
|
27
|
+
* I-B3 The plan is ordered oldest-first and carries no gaps: the caller prints the
|
|
28
|
+
* segments in the order given, so the scrollback keeps conversation order.
|
|
29
|
+
* I-B4 It never plans a row that is still on screen: `hiddenRows` is measured against the
|
|
30
|
+
* box height, and the box clips from the TOP only while it is bottom-anchored
|
|
31
|
+
* (`tail-anchor.ts`), which the caller checks before printing anything.
|
|
32
|
+
*
|
|
33
|
+
* PURE: no Ink, no I/O, no clock, no `node:*`. That is why the caller injects the measured
|
|
34
|
+
* heights and the printed counts instead of this module measuring anything itself: the same
|
|
35
|
+
* arithmetic then drives a unit test with an arbitrary table instead of a screen.
|
|
36
|
+
*/
|
|
37
|
+
import { SCROLLBACK_LIMITS } from './limits.js';
|
|
38
|
+
function wholeRows(value) {
|
|
39
|
+
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Which rows of the window the box is hiding, and how many of them are not printed yet.
|
|
43
|
+
*
|
|
44
|
+
* @param window The tail window, OLDEST FIRST, excluded entries already dropped.
|
|
45
|
+
* @param budgetRows Height of the box that shows the window (`band.previewRows`).
|
|
46
|
+
* @returns The print plan; `segments` is empty when nothing is hidden or when the first
|
|
47
|
+
* unstable entry sits at the top of the window (see I-B2).
|
|
48
|
+
*/
|
|
49
|
+
export function planOverflowPrint(window, budgetRows) {
|
|
50
|
+
const totalRows = window.reduce((sum, item) => sum + wholeRows(item.rows), 0);
|
|
51
|
+
// Rows past the wall are on screen too (the box is one box), so they count towards the
|
|
52
|
+
// height - they are simply never printable themselves.
|
|
53
|
+
const hiddenRows = Math.max(0, totalRows - wholeRows(budgetRows));
|
|
54
|
+
if (hiddenRows === 0)
|
|
55
|
+
return { segments: [], hiddenRows, totalRows };
|
|
56
|
+
const segments = [];
|
|
57
|
+
let offset = 0;
|
|
58
|
+
for (const [index, item] of window.entries()) {
|
|
59
|
+
// I-B2 - the wall. Nothing at or after the first unstable entry may print, and because
|
|
60
|
+
// the loop walks in order, "at or after" needs nothing more than a `break`.
|
|
61
|
+
if (!item.stable)
|
|
62
|
+
break;
|
|
63
|
+
// Every row from here down is visible: the box clips exactly `hiddenRows` rows off the
|
|
64
|
+
// top, so once the accumulated offset reaches it the plan is complete.
|
|
65
|
+
if (offset >= hiddenRows)
|
|
66
|
+
break;
|
|
67
|
+
const hiddenOfEntry = Math.min(wholeRows(item.rows), hiddenRows - offset);
|
|
68
|
+
const printed = wholeRows(item.printed);
|
|
69
|
+
if (hiddenOfEntry > printed) {
|
|
70
|
+
segments.push({ index, id: item.entry.id, fromRow: printed, toRow: hiddenOfEntry });
|
|
71
|
+
}
|
|
72
|
+
offset += wholeRows(item.rows);
|
|
73
|
+
}
|
|
74
|
+
return { segments, hiddenRows, totalRows };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The row that marks a WIDTH CHANGE inside one entry's printed history.
|
|
78
|
+
*
|
|
79
|
+
* "Rows already printed" is a width-relative quantity: a line that took two rows at 80
|
|
80
|
+
* columns takes one at 120. Continuing to slice at the old index would skip content
|
|
81
|
+
* (`W1` breaks) or repeat it silently. So a width change prints this seam and restarts the
|
|
82
|
+
* entry at the new width: every row stays reachable, and the only cost is one bounded
|
|
83
|
+
* repetition of the rows above the seam (documented in the README).
|
|
84
|
+
*/
|
|
85
|
+
export function reflowSeamRow(printedRows, printedCols, cols) {
|
|
86
|
+
return `[-- reflowed to ${cols} columns; ${printedRows} row(s) above were printed at ` +
|
|
87
|
+
`${printedCols} columns --]`;
|
|
88
|
+
}
|
|
89
|
+
/** The single wording of the per-entry row cap, shared by the renderer and the pipeline. */
|
|
90
|
+
export function elisionRow(hidden) {
|
|
91
|
+
return `[+${hidden} rows not printed - scrollback entry cap]`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Apply `maxRowsPerEntry` to the REMAINDER of one entry.
|
|
95
|
+
*
|
|
96
|
+
* The cap used to be a single "keep the first 399 rows, then say how many were dropped".
|
|
97
|
+
* That is still what a never-printed entry gets (`headRows === 0`), because it keeps the
|
|
98
|
+
* part of the answer the user has already scrolled past. An entry whose HEAD is already in
|
|
99
|
+
* the scrollback gets the opposite treatment on purpose: the notice goes FIRST and the LAST
|
|
100
|
+
* rows follow, because the tail is the part the reader is looking at on screen, and eliding
|
|
101
|
+
* it would delete the answer they just finished reading from their history.
|
|
102
|
+
*
|
|
103
|
+
* @param tail Rows still to print (`body.slice(fromRow)`).
|
|
104
|
+
* @param headRows Rows of this entry already printed.
|
|
105
|
+
*/
|
|
106
|
+
export function capPrintedRows(tail, headRows) {
|
|
107
|
+
const cap = SCROLLBACK_LIMITS.maxRowsPerEntry;
|
|
108
|
+
const printed = wholeRows(headRows);
|
|
109
|
+
if (printed + tail.length <= cap)
|
|
110
|
+
return tail;
|
|
111
|
+
if (printed === 0) {
|
|
112
|
+
const keep = Math.max(0, cap - 1);
|
|
113
|
+
return [...tail.slice(0, keep), elisionRow(tail.length - keep)];
|
|
114
|
+
}
|
|
115
|
+
// One row is spent on the notice itself.
|
|
116
|
+
const keep = Math.max(0, cap - printed - 1);
|
|
117
|
+
if (keep === 0)
|
|
118
|
+
return [elisionRow(tail.length)];
|
|
119
|
+
return [elisionRow(tail.length - keep), ...tail.slice(tail.length - keep)];
|
|
120
|
+
}
|
|
@@ -23,8 +23,18 @@ export interface EntryRenderOptions {
|
|
|
23
23
|
reducedMotion: boolean;
|
|
24
24
|
}
|
|
25
25
|
export interface EntryRenderer {
|
|
26
|
-
/** Rows for one entry at `cols` columns
|
|
26
|
+
/** Rows for one entry at `cols` columns, capped at `maxRowsPerEntry`; `[]` when it renders as nothing. */
|
|
27
27
|
renderEntry(entry: Entry, prev: Entry | undefined, cols: number, expanded?: boolean): string[];
|
|
28
|
+
/**
|
|
29
|
+
* The SAME rows, UNCAPPED.
|
|
30
|
+
*
|
|
31
|
+
* "Top give-way" (`overflow-print.ts`) slices one entry across two print passes, so it
|
|
32
|
+
* needs the real row count and the real rows: `capRows` truncates before returning, and a
|
|
33
|
+
* row budget computed from a truncated array puts the cut in the wrong place (a 800-row
|
|
34
|
+
* answer would compute its cut on 400 rows and print rows the screen is still showing).
|
|
35
|
+
* The cap is applied by the caller, once, where it knows how many rows are already printed.
|
|
36
|
+
*/
|
|
37
|
+
renderEntryRows(entry: Entry, prev: Entry | undefined, cols: number, expanded?: boolean): string[];
|
|
28
38
|
/** Rows for an arbitrary node (the startup header and opener). */
|
|
29
39
|
renderNode(node: React.ReactNode, cols: number): string[];
|
|
30
40
|
/** Options for LATER renders; already printed rows never change. */
|
|
@@ -17,6 +17,7 @@ import { Box, render } from 'ink';
|
|
|
17
17
|
import { EntryView } from '../Transcript.js';
|
|
18
18
|
import { getLogger } from '../../logging/logger.js';
|
|
19
19
|
import { SCROLLBACK_LIMITS } from './limits.js';
|
|
20
|
+
import { elisionRow } from './overflow-print.js';
|
|
20
21
|
/**
|
|
21
22
|
* Catches a throwing entry INSIDE the offscreen tree.
|
|
22
23
|
*
|
|
@@ -76,30 +77,52 @@ function trimTrailingBlank(rows) {
|
|
|
76
77
|
}
|
|
77
78
|
export function createEntryRenderer(initial) {
|
|
78
79
|
let options = initial;
|
|
79
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Resident offscreen instances, keyed by width.
|
|
82
|
+
*
|
|
83
|
+
* TWO SLOTS, NOT ONE. Ink reads `columns` once, at creation, so a width change needs a new
|
|
84
|
+
* instance - but a width change happens WITHIN one promotion pass as soon as the plan
|
|
85
|
+
* measures at the box width (`previewCols`) and prints at `contentCols`, and a single slot
|
|
86
|
+
* would then dispose and rebuild a complete Ink root on every such frame. Two slots cover
|
|
87
|
+
* both widths of the current geometry; a third width (an actual resize) evicts the oldest.
|
|
88
|
+
*/
|
|
89
|
+
const slots = new Map();
|
|
90
|
+
const MAX_SLOTS = 2;
|
|
80
91
|
let elidedRows = 0;
|
|
81
92
|
let failures = 0;
|
|
82
93
|
let guardKey = 0;
|
|
83
|
-
const
|
|
84
|
-
|
|
94
|
+
const disposeSlot = (cols) => {
|
|
95
|
+
const target = slots.get(cols);
|
|
96
|
+
if (!target)
|
|
85
97
|
return;
|
|
86
|
-
|
|
87
|
-
offscreen = null;
|
|
98
|
+
slots.delete(cols);
|
|
88
99
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
target.instance.unmount();
|
|
101
|
+
target.instance.cleanup();
|
|
91
102
|
}
|
|
92
103
|
catch {
|
|
93
104
|
// Tearing down a fake stream must never throw into an exit path.
|
|
94
105
|
}
|
|
95
106
|
};
|
|
107
|
+
const dispose = () => {
|
|
108
|
+
for (const cols of [...slots.keys()])
|
|
109
|
+
disposeSlot(cols);
|
|
110
|
+
};
|
|
96
111
|
/** Ink reads `columns` once, at creation, so a width change rebuilds the instance. */
|
|
97
112
|
const ensure = (cols) => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
const resident = slots.get(cols);
|
|
114
|
+
if (resident) {
|
|
115
|
+
// Refresh the recency order so the OTHER width is the eviction candidate.
|
|
116
|
+
slots.delete(cols);
|
|
117
|
+
slots.set(cols, resident);
|
|
118
|
+
return resident;
|
|
119
|
+
}
|
|
120
|
+
const evictable = [...slots.keys()][0];
|
|
121
|
+
if (evictable !== undefined && slots.size >= MAX_SLOTS)
|
|
122
|
+
disposeSlot(evictable);
|
|
123
|
+
const created = createOffscreen(cols);
|
|
124
|
+
slots.set(cols, created);
|
|
125
|
+
return created;
|
|
103
126
|
};
|
|
104
127
|
const capRows = (rows) => {
|
|
105
128
|
const cap = SCROLLBACK_LIMITS.maxRowsPerEntry;
|
|
@@ -107,9 +130,9 @@ export function createEntryRenderer(initial) {
|
|
|
107
130
|
return rows;
|
|
108
131
|
const hidden = rows.length - (cap - 1);
|
|
109
132
|
elidedRows += hidden;
|
|
110
|
-
return [...rows.slice(0, cap - 1),
|
|
133
|
+
return [...rows.slice(0, cap - 1), elisionRow(hidden)];
|
|
111
134
|
};
|
|
112
|
-
const
|
|
135
|
+
const renderFrame = (node, cols) => {
|
|
113
136
|
const target = ensure(Math.max(1, Math.floor(cols)));
|
|
114
137
|
target.last.value = null;
|
|
115
138
|
target.failure.error = NO_FAILURE;
|
|
@@ -122,27 +145,32 @@ export function createEntryRenderer(initial) {
|
|
|
122
145
|
throw failure;
|
|
123
146
|
if (frame === null)
|
|
124
147
|
throw new Error('offscreen Ink instance produced no frame');
|
|
125
|
-
return
|
|
148
|
+
return trimTrailingBlank(frame.split('\n'));
|
|
126
149
|
};
|
|
127
150
|
const failureRow = (label) => {
|
|
128
151
|
failures += 1;
|
|
129
152
|
return [`[aragon] ${label} could not be rendered`];
|
|
130
153
|
};
|
|
154
|
+
const entryFrame = (entry, prev, cols, expanded) => renderFrame(_jsx(EntryView, { entry: entry, prev: prev, expanded: expanded, thinkingVisible: options.thinkingVisible, reducedMotion: options.reducedMotion, density: options.density, theme: options.theme, caps: options.caps }), cols);
|
|
155
|
+
const renderEntryRows = (entry, prev, cols, expanded = false) => {
|
|
156
|
+
try {
|
|
157
|
+
return entryFrame(entry, prev, cols, expanded);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
getLogger().warn('cli', 'entry_render_failed', {
|
|
161
|
+
id: entry.id, kind: entry.kind, error: String(error),
|
|
162
|
+
});
|
|
163
|
+
return failureRow(`entry ${entry.id} (${entry.kind})`);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
131
166
|
return {
|
|
132
167
|
renderEntry(entry, prev, cols, expanded = false) {
|
|
133
|
-
|
|
134
|
-
return renderNode(_jsx(EntryView, { entry: entry, prev: prev, expanded: expanded, thinkingVisible: options.thinkingVisible, reducedMotion: options.reducedMotion, density: options.density, theme: options.theme, caps: options.caps }), cols);
|
|
135
|
-
}
|
|
136
|
-
catch (error) {
|
|
137
|
-
getLogger().warn('cli', 'entry_render_failed', {
|
|
138
|
-
id: entry.id, kind: entry.kind, error: String(error),
|
|
139
|
-
});
|
|
140
|
-
return failureRow(`entry ${entry.id} (${entry.kind})`);
|
|
141
|
-
}
|
|
168
|
+
return capRows(renderEntryRows(entry, prev, cols, expanded));
|
|
142
169
|
},
|
|
170
|
+
renderEntryRows,
|
|
143
171
|
renderNode(node, cols) {
|
|
144
172
|
try {
|
|
145
|
-
return
|
|
173
|
+
return capRows(renderFrame(node, cols));
|
|
146
174
|
}
|
|
147
175
|
catch (error) {
|
|
148
176
|
getLogger().warn('cli', 'node_render_failed', { error: String(error) });
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the live tail sits inside the band's preview box
|
|
3
|
+
* (tui-first-reply-bottom-aligned, plan A-prime).
|
|
4
|
+
*
|
|
5
|
+
* THE DEFECT THIS ANSWERS. The box is always the whole pool (`band.previewRows`, 24 rows
|
|
6
|
+
* on a 30-row terminal) while a first turn is four rows tall. A constant
|
|
7
|
+
* `justifyContent="flex-end"` therefore pushed those four rows against the composer and
|
|
8
|
+
* left every unused row ABOVE the conversation - the "the first reply starts at the
|
|
9
|
+
* bottom of the screen" report. The answer is conditional anchoring: top while the
|
|
10
|
+
* content fits, so the conversation is written downwards from under the header.
|
|
11
|
+
*
|
|
12
|
+
* WHY THE FALLBACK IS `bottom` AND NOT THE OTHER WAY ROUND. `flex-start` inside an
|
|
13
|
+
* `overflow="hidden"` box clips the NEWEST rows (Ink's clip rectangle is the box's
|
|
14
|
+
* `[top, top + height]`), and a streaming entry is never printable
|
|
15
|
+
* (`settled-boundary.ts` holds it unstable, and "printed is forever"). Overflowing to
|
|
16
|
+
* `flex-start` would hide the text the user is waiting for; overflowing to `flex-end`
|
|
17
|
+
* hides the oldest rows, which the tail window prints on its way out.
|
|
18
|
+
*
|
|
19
|
+
* THE THRESHOLD CARRIES A MARGIN. `rowOf` is an ESTIMATE, and the one available
|
|
20
|
+
* (`layout/virtual-window.ts::estimateEntryRows`) under-counts a `#` heading and a code
|
|
21
|
+
* block. The margin in `SCROLLBACK_LIMITS.tailAnchorSlackRows` absorbs that error on the
|
|
22
|
+
* `bottom` side, the only direction that cannot clip something new.
|
|
23
|
+
*
|
|
24
|
+
* PURE: no Ink, no I/O, no clock, and deliberately no import of `virtual-window.ts` (its
|
|
25
|
+
* import graph reaches a `.tsx` gutter). The row height is injected, so this stays three
|
|
26
|
+
* parameters and a unit test can drive it with any table it likes.
|
|
27
|
+
*/
|
|
28
|
+
import type { Entry } from '../../agent/reducer.js';
|
|
29
|
+
/** `top` = content grows down from the header; `bottom` = newest row hugs the composer. */
|
|
30
|
+
export type TailAnchor = 'top' | 'bottom';
|
|
31
|
+
/**
|
|
32
|
+
* Which alignment the live tail should use for the entries it is about to render.
|
|
33
|
+
*
|
|
34
|
+
* @param entries The entries THIS FRAME renders (already windowed by `selectTailEntries`;
|
|
35
|
+
* summing the whole conversation would make every session past the entry cap overflow and
|
|
36
|
+
* anchor to the bottom forever).
|
|
37
|
+
* @param budgetRows Height of the box that will clip them, in rows.
|
|
38
|
+
* @param rowOf Estimated height of one entry, including its separation row.
|
|
39
|
+
* @returns `top` when the estimates plus `tailAnchorSlackRows` fit in `budgetRows`,
|
|
40
|
+
* `bottom` otherwise (and for the first entry that does not fit, so the loop stops early).
|
|
41
|
+
*/
|
|
42
|
+
export declare function tailAnchor(entries: readonly Entry[], budgetRows: number, rowOf: (entry: Entry) => number): TailAnchor;
|
|
43
|
+
/**
|
|
44
|
+
* The anchor this frame really uses: MEASURED when the measurement exists, estimated
|
|
45
|
+
* otherwise.
|
|
46
|
+
*
|
|
47
|
+
* WHY A MEASURED ANSWER WINS (tui-final-reply-truncated-unscrollable). The estimate is an
|
|
48
|
+
* upper bound that charges a separation row this renderer may not draw, so it can claim the
|
|
49
|
+
* content overflows while it actually fits (the block sits at the bottom of a half-empty
|
|
50
|
+
* box - the very complaint `tui-first-reply-bottom-aligned` answered) and it under-counts a
|
|
51
|
+
* heading or a wrapped code block, so it can claim the content fits while the box is really
|
|
52
|
+
* clipping. The second error is the expensive one now: "top give-way" only prints while the
|
|
53
|
+
* box is bottom-anchored (that is the branch that clips the TOP), so a wrongly top-anchored
|
|
54
|
+
* box would clip the newest rows and print the oldest ones - the wrong half, twice.
|
|
55
|
+
*
|
|
56
|
+
* A measured total is available only when every window entry is stable AND already measured
|
|
57
|
+
* at the current geometry (`use-scrollback-view.ts` keeps that cache), which is exactly the
|
|
58
|
+
* frame on which a print decision is being made. Every other frame - streaming, resize,
|
|
59
|
+
* first sight of a new entry - falls back to the estimate, unchanged.
|
|
60
|
+
*
|
|
61
|
+
* @param measuredRows Measured height of the same entries, or `undefined` when unknown.
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveTailAnchor(input: {
|
|
64
|
+
entries: readonly Entry[];
|
|
65
|
+
budgetRows: number;
|
|
66
|
+
rowOf: (entry: Entry) => number;
|
|
67
|
+
measuredRows: number | undefined;
|
|
68
|
+
}): TailAnchor;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the live tail sits inside the band's preview box
|
|
3
|
+
* (tui-first-reply-bottom-aligned, plan A-prime).
|
|
4
|
+
*
|
|
5
|
+
* THE DEFECT THIS ANSWERS. The box is always the whole pool (`band.previewRows`, 24 rows
|
|
6
|
+
* on a 30-row terminal) while a first turn is four rows tall. A constant
|
|
7
|
+
* `justifyContent="flex-end"` therefore pushed those four rows against the composer and
|
|
8
|
+
* left every unused row ABOVE the conversation - the "the first reply starts at the
|
|
9
|
+
* bottom of the screen" report. The answer is conditional anchoring: top while the
|
|
10
|
+
* content fits, so the conversation is written downwards from under the header.
|
|
11
|
+
*
|
|
12
|
+
* WHY THE FALLBACK IS `bottom` AND NOT THE OTHER WAY ROUND. `flex-start` inside an
|
|
13
|
+
* `overflow="hidden"` box clips the NEWEST rows (Ink's clip rectangle is the box's
|
|
14
|
+
* `[top, top + height]`), and a streaming entry is never printable
|
|
15
|
+
* (`settled-boundary.ts` holds it unstable, and "printed is forever"). Overflowing to
|
|
16
|
+
* `flex-start` would hide the text the user is waiting for; overflowing to `flex-end`
|
|
17
|
+
* hides the oldest rows, which the tail window prints on its way out.
|
|
18
|
+
*
|
|
19
|
+
* THE THRESHOLD CARRIES A MARGIN. `rowOf` is an ESTIMATE, and the one available
|
|
20
|
+
* (`layout/virtual-window.ts::estimateEntryRows`) under-counts a `#` heading and a code
|
|
21
|
+
* block. The margin in `SCROLLBACK_LIMITS.tailAnchorSlackRows` absorbs that error on the
|
|
22
|
+
* `bottom` side, the only direction that cannot clip something new.
|
|
23
|
+
*
|
|
24
|
+
* PURE: no Ink, no I/O, no clock, and deliberately no import of `virtual-window.ts` (its
|
|
25
|
+
* import graph reaches a `.tsx` gutter). The row height is injected, so this stays three
|
|
26
|
+
* parameters and a unit test can drive it with any table it likes.
|
|
27
|
+
*/
|
|
28
|
+
import { SCROLLBACK_LIMITS } from './limits.js';
|
|
29
|
+
/**
|
|
30
|
+
* Which alignment the live tail should use for the entries it is about to render.
|
|
31
|
+
*
|
|
32
|
+
* @param entries The entries THIS FRAME renders (already windowed by `selectTailEntries`;
|
|
33
|
+
* summing the whole conversation would make every session past the entry cap overflow and
|
|
34
|
+
* anchor to the bottom forever).
|
|
35
|
+
* @param budgetRows Height of the box that will clip them, in rows.
|
|
36
|
+
* @param rowOf Estimated height of one entry, including its separation row.
|
|
37
|
+
* @returns `top` when the estimates plus `tailAnchorSlackRows` fit in `budgetRows`,
|
|
38
|
+
* `bottom` otherwise (and for the first entry that does not fit, so the loop stops early).
|
|
39
|
+
*/
|
|
40
|
+
export function tailAnchor(entries, budgetRows, rowOf) {
|
|
41
|
+
const budget = Number.isFinite(budgetRows) ? Math.max(0, Math.floor(budgetRows)) : 0;
|
|
42
|
+
let rows = 0;
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
rows += Math.max(0, rowOf(entry));
|
|
45
|
+
if (rows + SCROLLBACK_LIMITS.tailAnchorSlackRows > budget)
|
|
46
|
+
return 'bottom';
|
|
47
|
+
}
|
|
48
|
+
return 'top';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The anchor this frame really uses: MEASURED when the measurement exists, estimated
|
|
52
|
+
* otherwise.
|
|
53
|
+
*
|
|
54
|
+
* WHY A MEASURED ANSWER WINS (tui-final-reply-truncated-unscrollable). The estimate is an
|
|
55
|
+
* upper bound that charges a separation row this renderer may not draw, so it can claim the
|
|
56
|
+
* content overflows while it actually fits (the block sits at the bottom of a half-empty
|
|
57
|
+
* box - the very complaint `tui-first-reply-bottom-aligned` answered) and it under-counts a
|
|
58
|
+
* heading or a wrapped code block, so it can claim the content fits while the box is really
|
|
59
|
+
* clipping. The second error is the expensive one now: "top give-way" only prints while the
|
|
60
|
+
* box is bottom-anchored (that is the branch that clips the TOP), so a wrongly top-anchored
|
|
61
|
+
* box would clip the newest rows and print the oldest ones - the wrong half, twice.
|
|
62
|
+
*
|
|
63
|
+
* A measured total is available only when every window entry is stable AND already measured
|
|
64
|
+
* at the current geometry (`use-scrollback-view.ts` keeps that cache), which is exactly the
|
|
65
|
+
* frame on which a print decision is being made. Every other frame - streaming, resize,
|
|
66
|
+
* first sight of a new entry - falls back to the estimate, unchanged.
|
|
67
|
+
*
|
|
68
|
+
* @param measuredRows Measured height of the same entries, or `undefined` when unknown.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveTailAnchor(input) {
|
|
71
|
+
const { entries, budgetRows, rowOf, measuredRows } = input;
|
|
72
|
+
if (measuredRows === undefined || !Number.isFinite(measuredRows)) {
|
|
73
|
+
return tailAnchor(entries, budgetRows, rowOf);
|
|
74
|
+
}
|
|
75
|
+
const budget = Number.isFinite(budgetRows) ? Math.max(0, Math.floor(budgetRows)) : 0;
|
|
76
|
+
return measuredRows > budget ? 'bottom' : 'top';
|
|
77
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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; what a single entry
|
|
14
|
+
* taller than the region used to do is the whole subject of
|
|
15
|
+
* `overflow-print.ts` (tui-final-reply-truncated-unscrollable). The box still clips it
|
|
16
|
+
* from the top with `justifyContent="flex-end"`, but the rows it hides are now PRINTED
|
|
17
|
+
* into the terminal's scrollback, where the wheel already reaches, instead of waiting for
|
|
18
|
+
* `K` further entries (README, known limitation 2 - rewritten by that fix).
|
|
19
|
+
*
|
|
20
|
+
* PURE: no Ink, no I/O, no clock. `promote()` (the writer's only feeder) takes
|
|
21
|
+
* `printBoundary`'s result and keeps its own `printed` authority, so "printed is
|
|
22
|
+
* forward-only" (RV-7) is unchanged.
|
|
23
|
+
*
|
|
24
|
+
* PRINTABILITY != VISIBILITY. `isExcluded` entries (a non-terminal `service` card) never
|
|
25
|
+
* print and never take a slot in the window; an entry that is in the window may still be
|
|
26
|
+
* scrolled out of sight by a taller neighbour. That second gap is what the row-level plan
|
|
27
|
+
* closes, and `/perf`'s `window=<start>/<n> head=<rows>` is how it stays observable.
|
|
28
|
+
*/
|
|
29
|
+
import type { Entry } from '../../agent/reducer.js';
|
|
30
|
+
export interface TailWindow {
|
|
31
|
+
/** First index of the window inside the full entry list. */
|
|
32
|
+
start: number;
|
|
33
|
+
/** Entries inside the window, oldest first (excluded entries already dropped). */
|
|
34
|
+
entries: readonly Entry[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The index of the first entry that is still on screen: count backwards up to
|
|
38
|
+
* `maxEntries` PRINTABLE entries. Everything before it has left the region and is the
|
|
39
|
+
* writer's business.
|
|
40
|
+
*
|
|
41
|
+
* `maxEntries <= 0` returns `entries.length` - an empty window, which is the old
|
|
42
|
+
* behaviour ("everything printable may print") and the reason the guard is written as
|
|
43
|
+
* an early return rather than as a loop bound.
|
|
44
|
+
*/
|
|
45
|
+
export declare function tailWindowStart(entries: readonly Entry[], maxEntries: number): number;
|
|
46
|
+
/** `entries.slice(start)` with the entries that never print dropped. */
|
|
47
|
+
export declare function selectTailWindow(entries: readonly Entry[], maxEntries: number): TailWindow;
|
|
48
|
+
/**
|
|
49
|
+
* The print boundary: never print what the region is still showing.
|
|
50
|
+
*
|
|
51
|
+
* THIS IS THE ONLY PLACE THAT COMBINES "settled" WITH "in the window" (RV-P2-13), which
|
|
52
|
+
* is why the composition lives here instead of at the pump: `promote()` must keep
|
|
53
|
+
* receiving one number, and the two authorities it is derived from - `settledCount`
|
|
54
|
+
* (what may print at all) and `tailWindowStart` (what is still visible) - then cannot
|
|
55
|
+
* drift apart in a future edit.
|
|
56
|
+
*
|
|
57
|
+
* `settleAllEntries` is the OTHER authority, not a duplicate: it answers "what must be
|
|
58
|
+
* printed when the conversation is being torn down", while this answers "how far may
|
|
59
|
+
* this frame print".
|
|
60
|
+
*/
|
|
61
|
+
export declare function printBoundary(entries: readonly Entry[], runActive: boolean, maxEntries: number): number;
|