@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.
@@ -1,12 +1,19 @@
1
1
  /**
2
- * Streaming preview rows for the native-scroll live region
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
- * Entries that are not yet final cannot be printed, but the user must still see the
6
- * answer arrive. This builds a few PLAIN-TEXT rows from the unsettled tail of the
7
- * transcript; the shell colours them uniformly (`theme.muted`). The preview is a
8
- * visual buffer only: when an entry settles it is printed in its real, formatted
9
- * shape and the preview rows disappear (the "settle jump", documented in the README).
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; `[]` when it renders as nothing. */
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
- let offscreen = null;
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 dispose = () => {
84
- if (!offscreen)
94
+ const disposeSlot = (cols) => {
95
+ const target = slots.get(cols);
96
+ if (!target)
85
97
  return;
86
- const current = offscreen;
87
- offscreen = null;
98
+ slots.delete(cols);
88
99
  try {
89
- current.instance.unmount();
90
- current.instance.cleanup();
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
- if (offscreen && offscreen.cols === cols)
99
- return offscreen;
100
- dispose();
101
- offscreen = createOffscreen(cols);
102
- return offscreen;
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), `[+${hidden} rows not printed - scrollback entry cap]`];
133
+ return [...rows.slice(0, cap - 1), elisionRow(hidden)];
111
134
  };
112
- const renderNode = (node, cols) => {
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 capRows(trimTrailingBlank(frame.split('\n')));
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
- try {
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 renderNode(node, cols);
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;