@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,27 +1,33 @@
1
1
  /**
2
2
  * Height and column budget of the native-scroll PANEL BAND
3
- * (release-gate-and-native-panels §6.2).
3
+ * (release-gate-and-native-panels §6.2, tui-full-height-live-region D5).
4
4
  *
5
5
  * Native scroll mode drops the fixed frame, so it used to drop the panels with it:
6
6
  * the TODO rail lived in `AppShell`'s middle band and the subagent roster in the
7
7
  * viewport footer, and neither is rendered once `ScrollbackShell` takes over. The
8
- * band is the replacement - one row pool shared by the roster (`TeamPanel`), the
9
- * streaming tail and the rail (`TodoPanel`), sitting directly above the composer.
8
+ * band is the replacement - one row pool shared by the roster (`TeamPanel`), the tail
9
+ * and the rail (`TodoPanel`), sitting directly above the composer.
10
+ *
11
+ * `fill` IS THE FEATURE (D5). In `fill` mode the band takes the WHOLE pool:
12
+ * `bandRows = remaining`, `railRows = bandRows` (so the rail spans every row the user
13
+ * can see) and `previewRows = bandRows - teamRows` (so the tail column ends against
14
+ * the composer). Before `fill`, the band asked for "just enough" rows and every row it
15
+ * did not ask for became the canvas `gap` - the blank band between the result and the
16
+ * running card that this feature removes.
10
17
  *
11
18
  * PURE: no Ink, no clock, no terminal, no config read. Every invariant below is
12
19
  * therefore established BY CONSTRUCTION for all input combinations and can be
13
- * enumerated by a property test (AC-9..AC-14, AC-31):
20
+ * enumerated by a property test (AC-9..AC-14, AC-34, AC-F3/F4):
14
21
  *
15
22
  * - `bandRows <= remaining` and therefore `liveRows <= rows - 1` (I-4). Ink writes
16
23
  * `clearTerminal` - which contains `ESC[3J`, erasing the user's scrollback - as
17
- * soon as a frame is as tall as the terminal; that one row is the difference
18
- * between a tidy region and data loss.
24
+ * soon as a frame is as tall as the terminal.
19
25
  * - `previewCols + railCols === contentCols`, always (AC-10). The rail is a column
20
26
  * INSIDE the region, so the preview loses exactly the columns the rail takes and
21
27
  * nothing else (I-5: printing keeps the full `contentCols`).
22
28
  * - `teamRows > 0` leaves room for the rail plus one preview row (AC-12).
23
- * - `previewRows <= previewCap`, so the structural caps
24
- * `maxLiveTailRows` / `liveTailRowShare` keep binding (I-10).
29
+ * - `fill` mode: `bandRows === remaining`, `railRows === bandRows` when the rail is
30
+ * visible, and `teamRows + previewRows === bandRows`.
25
31
  *
26
32
  * THERE IS EXACTLY ONE CALL SITE: `buildScrollbackBudget` (I-8). This module is
27
33
  * exported so it can be tested, not so a second caller can allocate rows of its own
@@ -42,18 +48,14 @@ export type BandReason =
42
48
  | 'short';
43
49
  export interface LiveBandInput {
44
50
  /**
45
- * `buildFrameBudget().viewportRows + headerRows`: every row the band may use.
46
- * Computed once by `buildScrollbackBudget`; callers must not re-derive it.
51
+ * `buildFrameBudget().viewportRows`: every row the band may use (the pool). Computed
52
+ * once by `buildScrollbackBudget`; callers must not re-derive it.
47
53
  */
48
54
  remaining: number;
49
55
  /** `cols - 1`: width of the whole band. `previewCols` is derived from it. */
50
56
  contentCols: number;
51
57
  /** `todoRailWidth(contentCols)`; `layout/rail.ts` stays the width authority. */
52
58
  railCols: number;
53
- /** The existing two caps combined: `min(maxLiveTailRows, floor(rows * share))`. */
54
- previewCap: number;
55
- /** Plain-text rows `buildLivePreview` wants (the measuring authority is unchanged). */
56
- previewWanted: number;
57
59
  /** `state.todos.items.length` (0 = no active plan). */
58
60
  itemCount: number;
59
61
  /** `cfg.todo.panel`. */
@@ -65,10 +67,18 @@ export interface LiveBandInput {
65
67
  heldRailRows: number;
66
68
  /** Hysteresis: the roster rows the previous frame already used. */
67
69
  heldTeamRows: number;
70
+ /**
71
+ * The band takes the whole pool (native mode: always true, set by the budget).
72
+ *
73
+ * A plain boolean rather than "absence of a cap" because the difference is semantic:
74
+ * with `fill` the allocation is INDEPENDENT of the content (which is what makes the
75
+ * region the screen), while without it the band is sized to what it has to show.
76
+ */
77
+ fill: boolean;
68
78
  }
69
79
  export interface LiveBand {
70
80
  reason: BandReason;
71
- /** Band height: `teamRows + previewRows`, or `remaining` for an overlay. */
81
+ /** Band height: `remaining` in `fill` mode, `teamRows + previewRows` otherwise. */
72
82
  bandRows: number;
73
83
  teamRows: number;
74
84
  previewRows: number;
@@ -85,20 +95,19 @@ export interface LiveBand {
85
95
  /**
86
96
  * Allocate the band. THE ORDER OF THE STEPS IS THE SPECIFICATION - reordering them
87
97
  * changes the priority between "what this run is doing" (rail + roster) and "the
88
- * words arriving right now" (preview), which is a product decision, not a detail.
98
+ * words arriving right now" (tail), which is a product decision, not a detail.
89
99
  *
90
100
  * 0. Normalise every numeric input (`NaN` / `Infinity` / negatives become 0).
91
101
  * 1. An open overlay takes the whole pool (the band would otherwise double-count it).
92
- * 2. Seat the roster first, but never at the cost of the rail plus one preview row:
102
+ * 2. Seat the roster first, but never at the cost of the rail plus one tail row:
93
103
  * a dispatch is transient, the plan is the shape of the turn, both must fit.
94
104
  * 3. Decide the rail's visibility, in the same reason order as `buildTodoRailLayout`.
95
- * 4. RESERVE the rail's rows (`panelFullRows` floor - the 3-5 row compact form cannot
96
- * show which item is running, and the band reaches 6 rows far more often than the
97
- * full-screen rail does). A reservation, not the final value: see step 6.
98
- * 5. The preview takes what is left, clamped by `previewCap`.
99
- * 6. `bandRows = max(railReserved, teamRows + previewRows)`: with no unsettled
100
- * content `previewRows` is 0, yet the rail must still be there (the user is
101
- * reading a result and still needs the list).
105
+ * 4. RESERVE the rail's rows - only when the band is NOT filling. `fill` derives
106
+ * `railRows` from `bandRows` in step 7, so the reservation has no consumer there.
107
+ * 5. The tail takes what is left: the whole column in `fill` mode, `previewWanted`
108
+ * clamped by the pool otherwise.
109
+ * 6. `bandRows`: the pool in `fill` mode, `max(railReserved, teamRows + previewRows)`
110
+ * otherwise (where a rail must still be there with no tail rows).
102
111
  * 7. Columns: the input `contentCols` is the only width authority.
103
112
  * 8. Hand the late-arriving values (the hysteresis) back out for the next frame.
104
113
  */
@@ -1,27 +1,33 @@
1
1
  /**
2
2
  * Height and column budget of the native-scroll PANEL BAND
3
- * (release-gate-and-native-panels §6.2).
3
+ * (release-gate-and-native-panels §6.2, tui-full-height-live-region D5).
4
4
  *
5
5
  * Native scroll mode drops the fixed frame, so it used to drop the panels with it:
6
6
  * the TODO rail lived in `AppShell`'s middle band and the subagent roster in the
7
7
  * viewport footer, and neither is rendered once `ScrollbackShell` takes over. The
8
- * band is the replacement - one row pool shared by the roster (`TeamPanel`), the
9
- * streaming tail and the rail (`TodoPanel`), sitting directly above the composer.
8
+ * band is the replacement - one row pool shared by the roster (`TeamPanel`), the tail
9
+ * and the rail (`TodoPanel`), sitting directly above the composer.
10
+ *
11
+ * `fill` IS THE FEATURE (D5). In `fill` mode the band takes the WHOLE pool:
12
+ * `bandRows = remaining`, `railRows = bandRows` (so the rail spans every row the user
13
+ * can see) and `previewRows = bandRows - teamRows` (so the tail column ends against
14
+ * the composer). Before `fill`, the band asked for "just enough" rows and every row it
15
+ * did not ask for became the canvas `gap` - the blank band between the result and the
16
+ * running card that this feature removes.
10
17
  *
11
18
  * PURE: no Ink, no clock, no terminal, no config read. Every invariant below is
12
19
  * therefore established BY CONSTRUCTION for all input combinations and can be
13
- * enumerated by a property test (AC-9..AC-14, AC-31):
20
+ * enumerated by a property test (AC-9..AC-14, AC-34, AC-F3/F4):
14
21
  *
15
22
  * - `bandRows <= remaining` and therefore `liveRows <= rows - 1` (I-4). Ink writes
16
23
  * `clearTerminal` - which contains `ESC[3J`, erasing the user's scrollback - as
17
- * soon as a frame is as tall as the terminal; that one row is the difference
18
- * between a tidy region and data loss.
24
+ * soon as a frame is as tall as the terminal.
19
25
  * - `previewCols + railCols === contentCols`, always (AC-10). The rail is a column
20
26
  * INSIDE the region, so the preview loses exactly the columns the rail takes and
21
27
  * nothing else (I-5: printing keeps the full `contentCols`).
22
28
  * - `teamRows > 0` leaves room for the rail plus one preview row (AC-12).
23
- * - `previewRows <= previewCap`, so the structural caps
24
- * `maxLiveTailRows` / `liveTailRowShare` keep binding (I-10).
29
+ * - `fill` mode: `bandRows === remaining`, `railRows === bandRows` when the rail is
30
+ * visible, and `teamRows + previewRows === bandRows`.
25
31
  *
26
32
  * THERE IS EXACTLY ONE CALL SITE: `buildScrollbackBudget` (I-8). This module is
27
33
  * exported so it can be tested, not so a second caller can allocate rows of its own
@@ -36,20 +42,19 @@ function nonNegativeInt(value) {
36
42
  /**
37
43
  * Allocate the band. THE ORDER OF THE STEPS IS THE SPECIFICATION - reordering them
38
44
  * changes the priority between "what this run is doing" (rail + roster) and "the
39
- * words arriving right now" (preview), which is a product decision, not a detail.
45
+ * words arriving right now" (tail), which is a product decision, not a detail.
40
46
  *
41
47
  * 0. Normalise every numeric input (`NaN` / `Infinity` / negatives become 0).
42
48
  * 1. An open overlay takes the whole pool (the band would otherwise double-count it).
43
- * 2. Seat the roster first, but never at the cost of the rail plus one preview row:
49
+ * 2. Seat the roster first, but never at the cost of the rail plus one tail row:
44
50
  * a dispatch is transient, the plan is the shape of the turn, both must fit.
45
51
  * 3. Decide the rail's visibility, in the same reason order as `buildTodoRailLayout`.
46
- * 4. RESERVE the rail's rows (`panelFullRows` floor - the 3-5 row compact form cannot
47
- * show which item is running, and the band reaches 6 rows far more often than the
48
- * full-screen rail does). A reservation, not the final value: see step 6.
49
- * 5. The preview takes what is left, clamped by `previewCap`.
50
- * 6. `bandRows = max(railReserved, teamRows + previewRows)`: with no unsettled
51
- * content `previewRows` is 0, yet the rail must still be there (the user is
52
- * reading a result and still needs the list).
52
+ * 4. RESERVE the rail's rows - only when the band is NOT filling. `fill` derives
53
+ * `railRows` from `bandRows` in step 7, so the reservation has no consumer there.
54
+ * 5. The tail takes what is left: the whole column in `fill` mode, `previewWanted`
55
+ * clamped by the pool otherwise.
56
+ * 6. `bandRows`: the pool in `fill` mode, `max(railReserved, teamRows + previewRows)`
57
+ * otherwise (where a rail must still be there with no tail rows).
53
58
  * 7. Columns: the input `contentCols` is the only width authority.
54
59
  * 8. Hand the late-arriving values (the hysteresis) back out for the next frame.
55
60
  */
@@ -57,12 +62,11 @@ export function buildLiveBand(input) {
57
62
  const remaining = nonNegativeInt(input.remaining);
58
63
  const contentCols = nonNegativeInt(input.contentCols);
59
64
  const railCols = nonNegativeInt(input.railCols);
60
- const previewCap = nonNegativeInt(input.previewCap);
61
- const previewWanted = nonNegativeInt(input.previewWanted);
62
65
  const itemCount = nonNegativeInt(input.itemCount);
63
66
  const teamRowsNatural = nonNegativeInt(input.teamRowsNatural);
64
67
  const heldRailRows = nonNegativeInt(input.heldRailRows);
65
68
  const heldTeamRows = nonNegativeInt(input.heldTeamRows);
69
+ const fill = input.fill === true;
66
70
  if (input.overlayOpen) {
67
71
  return {
68
72
  reason: 'overlay', bandRows: remaining, teamRows: 0, previewRows: 0,
@@ -76,6 +80,14 @@ export function buildLiveBand(input) {
76
80
  // ever seat the roster - the panel this feature exists to restore would never appear
77
81
  // (found while implementing; recorded as IF-1 in the spec). The memory below only
78
82
  // ever ADDS height back after a squeeze; it never withholds it.
83
+ //
84
+ // THE FLOOR SURVIVES `fill` ON PURPOSE (spec §5.3.2; RV-P2-5, recorded as not changed
85
+ // back). It holds 4 rows back from the roster even when the rail is not drawn, which
86
+ // looks like waste in `fill` mode - but it is the vetted `AC-12` behaviour, and
87
+ // `TodoPanel`'s 3-5 row compact form needs those rows to name the running item. The
88
+ // only visible cost is 4 blank rows at the TOP of the left column (the tail still
89
+ // ends against the composer), which is not one of the three reported defects; changing
90
+ // it here would reopen a different feature in the same commit.
79
91
  const teamRows = teamCeiling;
80
92
  const reason = !input.panelEnabled ? 'disabled'
81
93
  : itemCount === 0 ? 'empty'
@@ -83,11 +95,20 @@ export function buildLiveBand(input) {
83
95
  : remaining - teamRows < SCROLLBACK_LIMITS.railBandMinRows ? 'short'
84
96
  : 'visible';
85
97
  const railVisible = reason === 'visible';
86
- const railReserved = railVisible
98
+ // NO CONSUMER IN `fill` MODE (step 7 derives `railRows` from `bandRows`); kept as the
99
+ // documented historical formula so the non-fill allocation stays readable and a future
100
+ // caller that does not fill cannot silently lose the rail's reservation.
101
+ const railReserved = railVisible && !fill
87
102
  ? Math.min(Math.max(heldRailRows, TODO_LIMITS.panelFullRows), Math.max(0, remaining - teamRows - 1))
88
103
  : 0;
89
- const previewRows = Math.min(previewWanted, previewCap, Math.max(0, remaining - teamRows - railReserved));
90
- const bandRows = Math.max(railReserved, teamRows + previewRows);
104
+ const previewRows = fill
105
+ ? Math.max(0, remaining - teamRows)
106
+ // Non-fill: the tail takes what the rail's reservation leaves. The two row caps that
107
+ // used to clamp this line (`previewCap` / `previewWanted`) are RETIRED (D9) - they
108
+ // were the only inputs that made this branch differ from `fill`, so a non-fill caller
109
+ // now gets the pool too. The branch stays as the named expression of the older policy.
110
+ : Math.max(0, remaining - teamRows - railReserved);
111
+ const bandRows = fill ? remaining : Math.max(railReserved, teamRows + previewRows);
91
112
  const railRows = railVisible ? bandRows : 0;
92
113
  const railColsOut = railVisible ? railCols : 0;
93
114
  return {
@@ -1,17 +1,26 @@
1
1
  /**
2
- * Height budget of the native-scroll LIVE REGION (tui-native-terminal-scroll §4.6).
2
+ * Height budget of the native-scroll LIVE REGION
3
+ * (tui-native-terminal-scroll §4.6, tui-full-height-live-region D1/D3/D5).
3
4
  *
4
5
  * I-S1 · `liveRows <= max(0, rows - 1)` for every input. Ink writes
5
6
  * `clearTerminal` (which contains `ESC[3J`, erasing the user's scrollback) as soon
6
- * as a frame is as tall as the terminal; a one-row margin is the only thing between
7
- * a tidy region and data loss, so it is established by construction here and
8
- * enumerated by a property test rather than trusted.
7
+ * as a frame is as tall as the terminal. This feature takes I-S1's ONE row of margin
8
+ * and nothing else: `liveRows === rows - 1` exactly, which is why the frame-height
9
+ * guard (I-F13 / AC-F17) exists - there is no slack left to absorb a stray row.
10
+ *
11
+ * THE REGION IS THE SCREEN. The region used to be only as tall as the band asked for,
12
+ * and every row it did not ask for became `gap` - blank rows ABOVE the content, which
13
+ * is what the user reported as "the app starts halfway down the screen" and as "a big
14
+ * blank between the result and the running card". The band now takes the whole pool
15
+ * (`fill`), so the region is `[header][band][composer][status]` top to bottom and
16
+ * `gap` cannot exist: `resolveCanvasLayout` is given `frameRows === capacity` and
17
+ * derives `gap = 0` (see the spec's §5.5 proof, which is why the writer needs no
18
+ * change at all).
9
19
  *
10
20
  * The composer, popup and status rows come from the SAME `buildFrameBudget` the
11
21
  * full-screen layout uses, so the two modes cannot disagree about how tall the
12
- * editor is. What differs is the top: there is no header, so the row it would
13
- * occupy joins the pool shared by the overlay, the panel band and the streaming
14
- * preview.
22
+ * editor is. What differs is the top: the header is a real slot here (D3), and the
23
+ * row it occupies is spent from the region, not from the printed transcript.
15
24
  *
16
25
  * THIS IS THE ONLY CALL SITE OF `buildLiveBand` (I-8). `remaining` is computed here
17
26
  * once and handed to the band; an `App` that computed its own copy would make
@@ -22,16 +31,15 @@ import { type LiveBand, type LiveBandInput } from './band.js';
22
31
  import { type FrameBudgetInput } from '../layout/budget.js';
23
32
  /**
24
33
  * Everything the band needs that this module derives for itself: `remaining`,
25
- * `contentCols`, `railCols`, `previewCap` and `previewWanted` are computed below, and
26
- * `overlayOpen` is taken from `ScrollbackBudgetInput` so the two can never disagree.
27
- * The caller supplies only the facts it alone knows (plan, panel switch, roster size,
28
- * hysteresis), never a number this module is supposed to own (I-8).
34
+ * `contentCols`, `railCols`, `overlayOpen` and `fill` are computed below - `fill` is
35
+ * NOT part of the caller's input (I-8: the budget decides it, and "the band fills the
36
+ * pool" is not a preference an `App` may hold). The caller supplies only the facts it
37
+ * alone knows (plan, panel switch, roster size, hysteresis), never a number this
38
+ * module is supposed to own.
29
39
  */
30
- export type ScrollbackBandInput = Omit<LiveBandInput, 'remaining' | 'contentCols' | 'railCols' | 'previewCap' | 'previewWanted' | 'overlayOpen'>;
40
+ export type ScrollbackBandInput = Omit<LiveBandInput, 'remaining' | 'contentCols' | 'railCols' | 'overlayOpen' | 'fill'>;
31
41
  export interface ScrollbackBudgetInput extends FrameBudgetInput {
32
42
  overlayOpen: boolean;
33
- /** Preview rows the content would like; the budget grants what fits. */
34
- liveTailWanted: number;
35
43
  /** The writer tripped its tall-frame guard: degrade to the placeholder until a resize. */
36
44
  forceInactive?: boolean;
37
45
  /**
@@ -49,6 +57,12 @@ export interface ScrollbackBudget {
49
57
  contentCols: number;
50
58
  /** Terminal below the 40x12 floor: the region is a one-row placeholder. */
51
59
  inactive: boolean;
60
+ /**
61
+ * The header slot, a real row inside the region (D3). 0 on a degraded frame: the
62
+ * placeholder draws no header, and reporting 1 there would break the row identity
63
+ * (I-F4) on exactly the frames `/perf` is read to explain.
64
+ */
65
+ headerRows: number;
52
66
  statusRows: 0 | 1 | 2;
53
67
  composerRows: number;
54
68
  composerSlotRows: number;
@@ -56,17 +70,15 @@ export interface ScrollbackBudget {
56
70
  popupRows: number;
57
71
  popupMaxHeight: number;
58
72
  overlayRows: number;
59
- /** Preview rows: the band's projection, 0 while an overlay is open. */
73
+ /** Tail rows: the band's left column below the roster (`= band.previewRows`). */
60
74
  liveTailRows: number;
61
- /** Rows above the composer: `= band.bandRows` (the identity below is unchanged). */
75
+ /** Rows above the composer: `= band.bandRows = remaining`. */
62
76
  topRows: number;
63
- /** Total live-region height; what Ink renders (`topRows + composer + status`). */
77
+ /** Total live-region height: `rows - 1`, the whole screen above the parked row. */
64
78
  liveRows: number;
65
79
  /** Height and column allocation of the panel band; the single authority is `band.ts`. */
66
80
  band: LiveBand;
67
81
  /** Preview/tail width: `contentCols - railCols`. Printing keeps `contentCols`. */
68
82
  previewCols: number;
69
- /** Rows the preview wanted before any clipping (feeds hysteresis and `/perf`). */
70
- previewWanted: number;
71
83
  }
72
84
  export declare function buildScrollbackBudget(input: ScrollbackBudgetInput): ScrollbackBudget;
@@ -1,17 +1,26 @@
1
1
  /**
2
- * Height budget of the native-scroll LIVE REGION (tui-native-terminal-scroll §4.6).
2
+ * Height budget of the native-scroll LIVE REGION
3
+ * (tui-native-terminal-scroll §4.6, tui-full-height-live-region D1/D3/D5).
3
4
  *
4
5
  * I-S1 · `liveRows <= max(0, rows - 1)` for every input. Ink writes
5
6
  * `clearTerminal` (which contains `ESC[3J`, erasing the user's scrollback) as soon
6
- * as a frame is as tall as the terminal; a one-row margin is the only thing between
7
- * a tidy region and data loss, so it is established by construction here and
8
- * enumerated by a property test rather than trusted.
7
+ * as a frame is as tall as the terminal. This feature takes I-S1's ONE row of margin
8
+ * and nothing else: `liveRows === rows - 1` exactly, which is why the frame-height
9
+ * guard (I-F13 / AC-F17) exists - there is no slack left to absorb a stray row.
10
+ *
11
+ * THE REGION IS THE SCREEN. The region used to be only as tall as the band asked for,
12
+ * and every row it did not ask for became `gap` - blank rows ABOVE the content, which
13
+ * is what the user reported as "the app starts halfway down the screen" and as "a big
14
+ * blank between the result and the running card". The band now takes the whole pool
15
+ * (`fill`), so the region is `[header][band][composer][status]` top to bottom and
16
+ * `gap` cannot exist: `resolveCanvasLayout` is given `frameRows === capacity` and
17
+ * derives `gap = 0` (see the spec's §5.5 proof, which is why the writer needs no
18
+ * change at all).
9
19
  *
10
20
  * The composer, popup and status rows come from the SAME `buildFrameBudget` the
11
21
  * full-screen layout uses, so the two modes cannot disagree about how tall the
12
- * editor is. What differs is the top: there is no header, so the row it would
13
- * occupy joins the pool shared by the overlay, the panel band and the streaming
14
- * preview.
22
+ * editor is. What differs is the top: the header is a real slot here (D3), and the
23
+ * row it occupies is spent from the region, not from the printed transcript.
15
24
  *
16
25
  * THIS IS THE ONLY CALL SITE OF `buildLiveBand` (I-8). `remaining` is computed here
17
26
  * once and handed to the band; an `App` that computed its own copy would make
@@ -21,7 +30,6 @@
21
30
  import { buildLiveBand } from './band.js';
22
31
  import { buildFrameBudget } from '../layout/budget.js';
23
32
  import { todoRailWidth } from '../layout/rail.js';
24
- import { SCROLLBACK_LIMITS } from './limits.js';
25
33
  /**
26
34
  * The band of a frame that shows no content at all (below the 40x12 floor, or after
27
35
  * the writer's tall-frame guard tripped). It is a REAL value rather than `undefined`
@@ -37,30 +45,35 @@ function degradedBand(contentCols) {
37
45
  export function buildScrollbackBudget(input) {
38
46
  const base = buildFrameBudget(input);
39
47
  const contentCols = Math.max(1, base.cols - 1);
40
- // `liveTailWanted` comes from a measurement, but a caller that never measured passes
41
- // `undefined`/`NaN`; normalising here keeps `NaN` out of `/perf` and out of the band.
42
- const previewWanted = Number.isFinite(input.liveTailWanted)
43
- ? Math.max(0, Math.floor(input.liveTailWanted))
44
- : 0;
45
48
  if (base.inactive || input.forceInactive === true) {
46
49
  return {
47
- rows: base.rows, cols: base.cols, contentCols, inactive: true, statusRows: 0,
48
- composerRows: 0, composerSlotRows: 0, composerCols: base.composerCols, popupRows: 0,
49
- popupMaxHeight: 0, overlayRows: 0, liveTailRows: 0, topRows: 0,
50
+ rows: base.rows, cols: base.cols, contentCols, inactive: true, headerRows: 0,
51
+ statusRows: 0, composerRows: 0, composerSlotRows: 0, composerCols: base.composerCols,
52
+ popupRows: 0, popupMaxHeight: 0, overlayRows: 0, liveTailRows: 0, topRows: 0,
50
53
  liveRows: Math.max(0, Math.min(1, base.rows - 1)),
51
- band: degradedBand(contentCols), previewCols: contentCols, previewWanted,
54
+ band: degradedBand(contentCols), previewCols: contentCols,
52
55
  };
53
56
  }
54
- // The full-screen frame spends one row on the header; here it joins the pool.
55
- const remaining = Math.max(0, base.viewportRows + base.headerRows);
56
- const tailCap = Math.min(SCROLLBACK_LIMITS.maxLiveTailRows, Math.floor(base.rows * SCROLLBACK_LIMITS.liveTailRowShare));
57
+ // D1 · H = C. `frameRows` is `rows - 1` by construction (`layout/budget.ts`), so the
58
+ // region ends exactly on the row above the writer's parked cursor.
59
+ const headerRows = 1;
60
+ const liveRows = Math.max(1, base.frameRows);
61
+ // The whole pool: everything between the header and the status line. Two independent
62
+ // derivations put its floor at 1, so `max(0, ...)` is defensive only:
63
+ // 1. `composerSlotRows = 2 + clampDraftRows(rows, draftRows)` and
64
+ // `draftMaxRows(rows) = max(1, min(6, floor(rows / 4)))`, so on the narrowest
65
+ // full-screen frame (`rows = 12`) `remaining >= 11 - 1 - 5 - 1 = 4`;
66
+ // 2. with the completion menu at its maximum, `popupMaxHeight` already subtracts
67
+ // this same pair of rows, so `remaining = popupMaxHeight + 1 - popupRows >= 1`.
68
+ const remaining = Math.max(0, liveRows - headerRows - base.composerSlotRows - base.statusRows);
57
69
  const band = buildLiveBand({
58
70
  ...input.band,
59
71
  remaining,
60
72
  contentCols,
61
73
  railCols: todoRailWidth(contentCols),
62
- previewCap: tailCap,
63
- previewWanted,
74
+ // The band fills the pool: no row of the region is left blank, which is the whole
75
+ // point of the feature (D5). Taken from HERE, never from `input.band`.
76
+ fill: true,
64
77
  // Taken from THIS input, never from `input.band`: an overlay that the frame knows
65
78
  // about but the band did not would draw no overlay at all, and the shell keys the
66
79
  // overlay's rows off the band.
@@ -69,13 +82,12 @@ export function buildScrollbackBudget(input) {
69
82
  const overlayRows = band.reason === 'overlay' ? band.bandRows : 0;
70
83
  const liveTailRows = band.previewRows;
71
84
  const topRows = band.bandRows;
72
- const liveRows = topRows + base.composerSlotRows + base.statusRows;
73
85
  return {
74
- rows: base.rows, cols: base.cols, contentCols, inactive: false,
86
+ rows: base.rows, cols: base.cols, contentCols, inactive: false, headerRows,
75
87
  statusRows: base.statusRows, composerRows: base.composerRows,
76
88
  composerSlotRows: base.composerSlotRows, composerCols: base.composerCols,
77
89
  popupRows: base.popupRows, popupMaxHeight: base.popupMaxHeight, overlayRows,
78
90
  liveTailRows, topRows, liveRows,
79
- band, previewCols: band.previewCols, previewWanted,
91
+ band, previewCols: band.previewCols,
80
92
  };
81
93
  }
@@ -18,19 +18,43 @@ export declare const SCROLLBACK_LIMITS: {
18
18
  readonly guardTripCount: 3;
19
19
  /** Session summary rows printed on exit. */
20
20
  readonly exitSummaryMaxRows: 4;
21
- /** Streaming preview rows, absolute cap. */
22
- readonly maxLiveTailRows: 12;
23
- /** ... and as a share of the terminal height. */
24
- readonly liveTailRowShare: 0.4;
25
21
  /**
26
- * Live-region `EntryView` instances per frame (release-gate-and-native-panels §6.5).
22
+ * Entries the region may hold on screen at once
23
+ * (tui-full-height-live-region D6; the WINDOW is defined by I-F8 and computed in
24
+ * `tail-window.ts`).
27
25
  *
28
- * A STRUCTURAL CAP of the same kind as `maxLiveTailRows`: a screenful is at most 12
29
- * rows plus 4 component instances, so the band costs the same order of magnitude as
30
- * the full-screen live tail and the render governor and every `memo` boundary keep
31
- * working. Raising it makes the per-frame cost grow linearly with no windowing.
26
+ * 10 is DERIVED, not a preference. The band's left column is the whole pool
27
+ * (`pool = rows - 1 - header - composer - status`, i.e. 24 rows on a 30-row
28
+ * terminal) and one entry occupies about 2.4 rows, so a window of 4 would leave
29
+ * the column half empty - the blank band this feature removes. It stays in the
30
+ * per-frame COST class the old value of 4 was measured in: `EntryView` is
31
+ * memoised and only the streaming entry re-renders, so this bounds component
32
+ * instances, not per-frame work.
33
+ *
34
+ * There is deliberately NO companion row cap any more (retired: the old
35
+ * `maxLiveTailRows` / `liveTailRowShare` pair). The rows a tail may take are
36
+ * `band.previewRows` - the whole left column - and "the region can never be
37
+ * taller than the screen" is `liveRows === rows - 1`'s job (I-F5), not a second
38
+ * number's. Those two caps disagreed with the pool, and that disagreement WAS the
39
+ * blank band between the result and the running card.
40
+ */
41
+ readonly liveTailMaxEntries: 10;
42
+ /**
43
+ * Safety margin the live tail asks for before it anchors its content to the TOP of
44
+ * the region (tui-first-reply-bottom-aligned, plan A-prime; the decision itself is
45
+ * `tail-anchor.ts`).
46
+ *
47
+ * 2 is DERIVED, not a preference. The anchor is decided from
48
+ * `layout/virtual-window.ts::estimateEntryRows`, which charges one separation row per
49
+ * entry and counts RAW markdown lines - so it UNDER-estimates a `#` heading (which
50
+ * also draws an `hrule`) and a code block (whose rail narrows the text by two columns,
51
+ * making long lines wrap). Inside a virtual window that error self-corrects when the
52
+ * entry is measured; the live region has no measurement pass, so the same error would
53
+ * decide which rows get CLIPPED - and `flex-start` clips the NEWEST rows.
54
+ * The margin must therefore always stay on the `bottom` side: a needless fallback
55
+ * costs one stacked frame, an early anchor hides the text the user is waiting for.
32
56
  */
33
- readonly liveTailMaxEntries: 4;
57
+ readonly tailAnchorSlackRows: 2;
34
58
  /**
35
59
  * Rows the rail needs before the band will show it: `TODO_LIMITS.panelMinRows + 1`.
36
60
  *
@@ -18,19 +18,43 @@ export const SCROLLBACK_LIMITS = {
18
18
  guardTripCount: 3,
19
19
  /** Session summary rows printed on exit. */
20
20
  exitSummaryMaxRows: 4,
21
- /** Streaming preview rows, absolute cap. */
22
- maxLiveTailRows: 12,
23
- /** ... and as a share of the terminal height. */
24
- liveTailRowShare: 0.4,
25
21
  /**
26
- * Live-region `EntryView` instances per frame (release-gate-and-native-panels §6.5).
22
+ * Entries the region may hold on screen at once
23
+ * (tui-full-height-live-region D6; the WINDOW is defined by I-F8 and computed in
24
+ * `tail-window.ts`).
27
25
  *
28
- * A STRUCTURAL CAP of the same kind as `maxLiveTailRows`: a screenful is at most 12
29
- * rows plus 4 component instances, so the band costs the same order of magnitude as
30
- * the full-screen live tail and the render governor and every `memo` boundary keep
31
- * working. Raising it makes the per-frame cost grow linearly with no windowing.
26
+ * 10 is DERIVED, not a preference. The band's left column is the whole pool
27
+ * (`pool = rows - 1 - header - composer - status`, i.e. 24 rows on a 30-row
28
+ * terminal) and one entry occupies about 2.4 rows, so a window of 4 would leave
29
+ * the column half empty - the blank band this feature removes. It stays in the
30
+ * per-frame COST class the old value of 4 was measured in: `EntryView` is
31
+ * memoised and only the streaming entry re-renders, so this bounds component
32
+ * instances, not per-frame work.
33
+ *
34
+ * There is deliberately NO companion row cap any more (retired: the old
35
+ * `maxLiveTailRows` / `liveTailRowShare` pair). The rows a tail may take are
36
+ * `band.previewRows` - the whole left column - and "the region can never be
37
+ * taller than the screen" is `liveRows === rows - 1`'s job (I-F5), not a second
38
+ * number's. Those two caps disagreed with the pool, and that disagreement WAS the
39
+ * blank band between the result and the running card.
40
+ */
41
+ liveTailMaxEntries: 10,
42
+ /**
43
+ * Safety margin the live tail asks for before it anchors its content to the TOP of
44
+ * the region (tui-first-reply-bottom-aligned, plan A-prime; the decision itself is
45
+ * `tail-anchor.ts`).
46
+ *
47
+ * 2 is DERIVED, not a preference. The anchor is decided from
48
+ * `layout/virtual-window.ts::estimateEntryRows`, which charges one separation row per
49
+ * entry and counts RAW markdown lines - so it UNDER-estimates a `#` heading (which
50
+ * also draws an `hrule`) and a code block (whose rail narrows the text by two columns,
51
+ * making long lines wrap). Inside a virtual window that error self-corrects when the
52
+ * entry is measured; the live region has no measurement pass, so the same error would
53
+ * decide which rows get CLIPPED - and `flex-start` clips the NEWEST rows.
54
+ * The margin must therefore always stay on the `bottom` side: a needless fallback
55
+ * costs one stacked frame, an early anchor hides the text the user is waiting for.
32
56
  */
33
- liveTailMaxEntries: 4,
57
+ tailAnchorSlackRows: 2,
34
58
  /**
35
59
  * Rows the rail needs before the band will show it: `TODO_LIMITS.panelMinRows + 1`.
36
60
  *
@@ -1,18 +1,28 @@
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
  */
13
20
  import type { Entry } from '../../agent/reducer.js';
14
21
  export interface LivePreviewInput {
15
- /** `entries.slice(settledCount)` — may include excluded entries; they are skipped. */
22
+ /**
23
+ * The entries to project, oldest first - the tail window in native mode. May include
24
+ * excluded entries; they are skipped.
25
+ */
16
26
  unsettled: readonly Entry[];
17
27
  /** Content width (`cols - 1`). */
18
28
  cols: number;
@@ -22,5 +32,3 @@ export interface LivePreviewInput {
22
32
  }
23
33
  /** The last `rows` preview rows, oldest first. `[]` when `rows <= 0`. */
24
34
  export declare function buildLivePreview(input: LivePreviewInput): string[];
25
- /** How many preview rows the unsettled entries would like, before budget clamping. */
26
- export declare function wantedPreviewRows(input: Omit<LivePreviewInput, 'rows'>, cap: number): number;