@aragon-agent/cli 0.6.33 → 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.
@@ -84,6 +84,16 @@ export interface BandPerf {
84
84
  */
85
85
  windowStart: number;
86
86
  windowEntries: number;
87
+ /**
88
+ * Rows the TOP GIVE-WAY has printed for entries that are still inside the window.
89
+ *
90
+ * `window=` says what the region holds; `head=` says how much of it the region could
91
+ * not show and therefore gave back to the terminal scrollback
92
+ * (tui-final-reply-truncated-unscrollable). Zero is the healthy answer for a
93
+ * conversation that always fits; a growing number means the box is smaller than the
94
+ * reply, which is exactly the state that used to be unrecoverable.
95
+ */
96
+ headRows: number;
87
97
  }
88
98
  /**
89
99
  * Why the right-edge scrollbar is or is not usable, in one record
@@ -139,7 +139,7 @@ function formatBandLine(band) {
139
139
  `tail=${band.previewRows} pool=${band.bandRows} ` +
140
140
  `cols=${band.contentCols}/${band.previewCols} ` +
141
141
  `reason=${band.reason} held=${band.heldRailRows}/${band.heldTeamRows} ` +
142
- `window=${band.windowStart}/${band.windowEntries}`;
142
+ `window=${band.windowStart}/${band.windowEntries} head=${band.headRows}`;
143
143
  }
144
144
  /**
145
145
  * Format a snapshot as the seven-line block documented in §5.4 (plus the scrollbar
package/dist/ui/App.js CHANGED
@@ -2102,12 +2102,30 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2102
2102
  // `ctrl+o log` hint, the keystroke would silently expand AN OLDER TOOL
2103
2103
  // CARD, and nothing would error. The terminal one-row record is skipped -
2104
2104
  // it has nothing to reveal, and expanding it would consume the press.
2105
- const last = [...stateRef.current.entries]
2105
+ //
2106
+ // TWO TIERS, AND THE ORDER IS THE WHOLE FIX (tui-final-reply-truncated-
2107
+ // unscrollable B1). The closed list above answers "which card is this key for",
2108
+ // and it must keep answering it EXACTLY as before: a settled answer is almost
2109
+ // always the newest entry, so adding `assistant` to THIS filter would take the
2110
+ // press away from the tool card the user just watched finish. Only when no card
2111
+ // is asking for the key does it fall through to the Agent's last answer - which
2112
+ // is the escape hatch `docs/plans/tui-full-height-live-region/spec.md` §13
2113
+ // (known limitation 2) already promised and never wired up.
2114
+ const entries = stateRef.current.entries;
2115
+ const card = [...entries]
2106
2116
  .reverse()
2107
2117
  .find((e) => e.kind === 'tool' ||
2108
2118
  e.kind === 'team' ||
2109
2119
  e.kind === 'compaction' ||
2110
2120
  (e.kind === 'service' && !e.terminal));
2121
+ // NATIVE MODE ONLY. The fallback exists because native mode cannot scroll inside the
2122
+ // frame - the answer's head is reachable only if it is printed. Full-screen mode has a
2123
+ // viewport that already reaches it, and it has no `expanded` rendering for an answer,
2124
+ // so falling through there would consume the press and show nothing.
2125
+ const answer = card === undefined && native
2126
+ ? [...entries].reverse().find((e) => e.kind === 'assistant' && !e.streaming)
2127
+ : undefined;
2128
+ const last = card ?? answer;
2111
2129
  // Native mode cannot rewrite what is already printed, so it PRINTS an expanded
2112
2130
  // copy as a new block instead of toggling the old one (spec 4.7).
2113
2131
  if (last && native) {
@@ -2339,6 +2357,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2339
2357
  itemCount: state.todos?.items.length ?? 0,
2340
2358
  panelEnabled: cfg.todo.panel,
2341
2359
  teamRowsNatural: teamLayout.rowCount,
2360
+ // The top give-way measures a window entry's height, and an expanded tool card is a
2361
+ // different height from a collapsed one - a forgotten value would print rows the region
2362
+ // is not drawing.
2363
+ expandedToolIds: state.expandedToolIds,
2342
2364
  });
2343
2365
  scrollbackViewRef.current = scrollbackView;
2344
2366
  // The two hooks `cli.tsx` calls from OUTSIDE React: the exit summary (every exit
@@ -2436,9 +2458,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2436
2458
  contentCols: nativeBudget.contentCols,
2437
2459
  // The tail window, joined here because it belongs to the VIEW pipeline rather
2438
2460
  // than to the band: `window=<start>/<n>` is what makes the visible-vs-printed
2439
- // gap observable (known limitation 5).
2461
+ // gap observable (known limitation 5), and `head=` counts the rows the top
2462
+ // give-way printed for it, so the mechanism is never invisible.
2440
2463
  windowStart: scrollbackView.windowStart,
2441
2464
  windowEntries: scrollbackView.tailEntries.length,
2465
+ headRows: scrollbackView.headRows,
2442
2466
  }
2443
2467
  : null,
2444
2468
  // A getter: the bridge's `frameReady` and geometry change outside React renders.
@@ -2584,7 +2608,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2584
2608
  // fallback for a frame with no preview rows. The WHOLE list is passed: the window
2585
2609
  // belongs to `tail-window.ts`, and `prev` must come from the same list.
2586
2610
  tail: nativeBudget.band.previewRows > 0
2587
- ? (_jsx(LiveTail, { entries: state.entries, maxEntries: SCROLLBACK_LIMITS.liveTailMaxEntries, rows: nativeBudget.band.previewRows, cols: nativeBudget.previewCols, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: cfg.density, nowSec: nowSec, theme: theme, caps: caps }))
2611
+ ? (_jsx(LiveTail, { entries: state.entries, maxEntries: SCROLLBACK_LIMITS.liveTailMaxEntries, rows: nativeBudget.band.previewRows, cols: nativeBudget.previewCols, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: cfg.density, nowSec: nowSec, theme: theme, caps: caps, measuredRows: scrollbackView.measuredTailRows }))
2588
2612
  : undefined, team: nativeTeam, rail: nativeRail, composer: composer, status: statusNode, details: detailsNode, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), theme: theme }));
2589
2613
  }
2590
2614
  return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, viewportRows: frameBudget.viewportRows, composer: composer, composerSlotRows: frameBudget.composerSlotRows, statusRows: frameBudget.statusRows, details: detailsNode, status: statusNode }));
@@ -29,20 +29,30 @@
29
29
  * content would make the region taller than `budget.liveRows`, and a frame as tall as
30
30
  * the terminal makes Ink erase the user's scrollback.
31
31
  *
32
- * TWO SLOTS ARE NOT BOTTOM-ALIGNED, AND ONLY TWO (D3 / D4 / I-F7):
32
+ * THE BAND'S PREVIEW BOX IS TOP-ALIGNED; THE OTHER SLOTS ARE FIXED
33
+ * (D3 / D4 / I-F7; tui-first-reply-bottom-aligned, plan A-prime):
33
34
  * - `header` is the fixed 1-row slot at the very top. It is NOT printed into the
34
35
  * scrollback any more: at `H = rows - 1` any printed row is pushed off the screen by
35
36
  * the next frame, so "print the header" would mean "the header is never visible".
36
- * - `opener` is the empty-session card, and it is the one thing inside the band that is
37
- * TOP-aligned: a 12-row card bottom-aligned in a 24-row column would leave 12 blank
38
- * rows in the upper half of the screen - the original defect, still inside the
39
- * region, where the `gap` assertion could not see it. `opener` and `tail` are
40
- * mutually exclusive by construction (`App` keys them off `entries.length === 0`),
41
- * and they share one box because two boxes would be one more height to get wrong.
37
+ * - the band's preview box is TOP-aligned unconditionally, which is why the alignment is
38
+ * no longer a function of `opener`. `opener` is a 12-row card in a 24-row column, so
39
+ * bottom-aligning it left 12 blank rows in the upper half of the screen - the original
40
+ * defect, still inside the region, where the `gap` assertion could not see it
41
+ * (RV-P0-1). `tail` had the same shape for a short conversation: four rows pressed
42
+ * against the composer with every unused row ABOVE them, which is the defect plan
43
+ * A-prime fixes. `tail` therefore decides its own alignment one level down
44
+ * (`scrollback/tail-anchor.ts`): top while its content fits, and the old `flex-end`
45
+ * only on the frames where the content overflows and the newest row must stay visible.
46
+ * `opener` and `tail` share this box because two boxes would be one more height to
47
+ * get wrong, and they are mutually exclusive by construction (`App` keys them off
48
+ * `entries.length === 0`).
42
49
  *
43
50
  * `liveTailLines` STAYS. It is the plain-text fallback for the frames where the real
44
51
  * tail is not mounted (and the floor of the measurement path); `tail` wins when both
45
- * are given, so the two can never be drawn on top of each other.
52
+ * are given, so the two can never be drawn on top of each other. It is sliced to
53
+ * `budget.liveTailRows` below, which is the same number as `band.previewRows`: that slice
54
+ * is what makes the unconditional `flex-start` safe here, because a fallback taller than
55
+ * the box would clip its NEWEST line - the one direction this box must never clip.
46
56
  */
47
57
  import React from 'react';
48
58
  import type { Theme } from './theme.js';
@@ -60,8 +70,8 @@ export interface ScrollbackShellProps {
60
70
  /** Plain preview rows: the fallback when `tail` is absent (and what `/perf` counts). */
61
71
  liveTailLines: readonly string[];
62
72
  /**
63
- * The empty-session card. Mutually exclusive with `tail`, TOP-aligned, and the only
64
- * thing in this shell that is not bottom-aligned or a fixed slot (RV-P0-1).
73
+ * The empty-session card. Mutually exclusive with `tail`, TOP-aligned like the rest of
74
+ * the band's preview box (RV-P0-1).
65
75
  */
66
76
  opener?: React.ReactNode;
67
77
  /** Real components for the tail window (`LiveTail`). */
@@ -6,13 +6,14 @@ export function ScrollbackShell({ budget, header, overlay, liveTailLines, opener
6
6
  return (_jsx(Box, { flexDirection: "column", height: budget.liveRows, width: width, overflow: "hidden", flexShrink: 0, children: budget.liveRows > 0 ? placeholder : null }));
7
7
  }
8
8
  const band = budget.band;
9
- // `opener ?? tail ?? lines` is ONE slot, not three: whichever is mounted decides the
10
- // alignment of the box, so the two can never both be drawn and fight for the height.
9
+ // `opener ?? tail ?? lines` is ONE slot, not three: they are mutually exclusive, so
10
+ // the two can never both be drawn and fight for the height. This box's alignment is
11
+ // fixed (`flex-start`); `tail` alone may fall back to `flex-end` when it overflows.
11
12
  const leftColumn = opener ?? tail;
12
13
  const tailLines = liveTailLines.slice(-budget.liveTailRows);
13
14
  // The overlay and the band are alternatives, not siblings: the band reports
14
15
  // `bandRows = remaining` for an overlay frame, so drawing both would double the
15
16
  // region's height and take the frame to the terminal height (the `ESC[3J` case).
16
- const top = band.reason === 'overlay' ? (_jsx(Box, { flexDirection: "column", height: budget.overlayRows, flexShrink: 0, overflow: "hidden", children: overlay })) : band.bandRows > 0 ? (_jsxs(Box, { flexDirection: "row", height: band.bandRows, flexShrink: 0, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", width: budget.previewCols, flexShrink: 0, overflow: "hidden", children: [band.teamRows > 0 && (_jsx(Box, { height: band.teamRows, flexShrink: 0, overflow: "hidden", children: team })), band.previewRows > 0 && (_jsx(Box, { flexDirection: "column", height: band.previewRows, flexShrink: 0, justifyContent: opener ? 'flex-start' : 'flex-end', overflow: "hidden", children: leftColumn ?? tailLines.map((line, index) => (_jsx(Text, { wrap: "truncate", color: theme.muted, children: line }, index))) }))] }), band.railRows > 0 && (_jsx(Box, { height: band.railRows, width: band.railCols, flexShrink: 0, overflow: "hidden", children: rail }))] })) : null;
17
+ const top = band.reason === 'overlay' ? (_jsx(Box, { flexDirection: "column", height: budget.overlayRows, flexShrink: 0, overflow: "hidden", children: overlay })) : band.bandRows > 0 ? (_jsxs(Box, { flexDirection: "row", height: band.bandRows, flexShrink: 0, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", width: budget.previewCols, flexShrink: 0, overflow: "hidden", children: [band.teamRows > 0 && (_jsx(Box, { height: band.teamRows, flexShrink: 0, overflow: "hidden", children: team })), band.previewRows > 0 && (_jsx(Box, { flexDirection: "column", height: band.previewRows, flexShrink: 0, justifyContent: "flex-start", overflow: "hidden", children: leftColumn ?? tailLines.map((line, index) => (_jsx(Text, { wrap: "truncate", color: theme.muted, children: line }, index))) }))] }), band.railRows > 0 && (_jsx(Box, { height: band.railRows, width: band.railCols, flexShrink: 0, overflow: "hidden", children: rail }))] })) : null;
17
18
  return (_jsxs(Box, { flexDirection: "column", height: budget.liveRows, width: width, overflow: "hidden", flexShrink: 0, children: [budget.headerRows > 0 && (_jsx(Box, { height: budget.headerRows, flexShrink: 0, overflow: "hidden", children: header })), top, _jsx(Box, { flexDirection: "column", height: budget.composerSlotRows, justifyContent: "flex-end", flexShrink: 0, overflow: "hidden", children: composer }), _jsx(Box, { height: budget.statusRows > 0 ? 1 : 0, flexShrink: 0, overflow: "hidden", children: status }), budget.statusRows === 2 && (_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: details }))] }));
18
19
  }
@@ -16,7 +16,7 @@
16
16
  * frame; the region therefore holds the last `SCROLLBACK_LIMITS.liveTailMaxEntries`
17
17
  * PRINTABLE entries, settled or not, and an entry is printed when it LEAVES that window.
18
18
  * The window is `tail-window.ts`'s definition; this component only slides it over the
19
- * list and lets the fixed-height box clip from the top.
19
+ * list and lets the fixed-height box clip from the top when it overflows.
20
20
  *
21
21
  * IT RENDERS `EntryView` AND NOTHING ELSE. The mapping from an entry to its component
22
22
  * (`assistant` / `tool` / `team` / `todo` / service cards, notice frames, the `null`
@@ -26,12 +26,39 @@
26
26
  * left rail glyph and the transcript's would keep it, and the two halves of one screen
27
27
  * would disagree about the same entry.
28
28
  *
29
- * `justifyContent="flex-end"` PLUS `overflow="hidden"` IS CORRECTNESS, NOT STYLE: when
30
- * the content is taller than the budget the rows that get clipped are the OLDEST ones,
31
- * so the newest line always sits against the composer - the same promise
32
- * `scrollback/canvas.ts` makes about the live tail. (`ScrollbackShell` flips this to
33
- * `flex-start` for the empty-session opener, which is the one frame where the content
34
- * must hug the HEADER instead; it never renders this component.)
29
+ * TOP-ANCHORED WHILE THE CONTENT FITS, BOTTOM-ANCHORED WHEN IT DOES NOT
30
+ * (tui-first-reply-bottom-aligned, plan A-prime). A constant `flex-end` is what a user
31
+ * reports as "the first reply shows up at the bottom": the box is always the whole pool,
32
+ * so a four-row first turn was pushed against the composer and every unused row sat ABOVE
33
+ * the conversation. `tailAnchor` therefore sums the estimated height of exactly the
34
+ * entries this frame renders and asks for `flex-start` while they fit - the conversation
35
+ * is then written downwards from under the header, the way a terminal writes.
36
+ *
37
+ * THE OVERFLOW FRAME STAYS `flex-end`, AND THAT IS THE OTHER HALF OF THE FIX.
38
+ * `flex-start` PLUS `overflow="hidden"` clips the NEWEST rows (Ink's clip rectangle is
39
+ * the box's own `[top, top + height]`), and a streaming entry can never be printed into
40
+ * the scrollback to make up for it (`settled-boundary.ts` holds it unstable, and printed
41
+ * rows are permanent). `flex-end` clips the OLDEST rows instead, which is the promise
42
+ * `scrollback/canvas.ts` makes about the live tail: the newest line always sits against
43
+ * the composer. Those clipped rows are what `overflow-print.ts` prints, which is why the
44
+ * branch has to be `bottom` before anything is printed - and why `measuredRows` exists.
45
+ *
46
+ * THE SWITCH COSTS ONE DOWNWARD MOVE ON A STREAMING FRAME, AND IT IS NOT FREE. The two
47
+ * branches are only pixel-identical where the content EXACTLY fills the box, and the
48
+ * estimate cannot know that: `estimateEntryRows` charges every entry a separation row while
49
+ * `density.ts::separationRows` only draws one above a `user` entry, so the frame that
50
+ * flips is still a few rows short of the box and the block drops by those rows once -
51
+ * three on a 30-row terminal (the margin plus that over-count) - before it starts
52
+ * scrolling up a row per line. Accepted and pinned by
53
+ * `scrollback-first-reply-anchor.test.tsx`: anchoring to the top until the content
54
+ * really fills the box is exactly the frame that clips the newest row, and measuring the
55
+ * box every frame is the cost plan A-prime exists to avoid.
56
+ *
57
+ * A SETTLED WINDOW IS MEASURED ANYWAY, so the move is exact there (`measuredRows`).
58
+ * `use-scrollback-view.ts` has to measure the window to decide what the box hides, and
59
+ * `resolveTailAnchor` lets that measurement decide the anchor: `flex-end` exactly when the
60
+ * content is taller than the box. So the glitch above survives only on frames where no
61
+ * measurement exists yet - every streaming frame, i.e. exactly the case the pin covers.
35
62
  *
36
63
  * BOUNDED: at most `SCROLLBACK_LIMITS.liveTailMaxEntries` component instances per frame
37
64
  * (I-10). That is what keeps the per-frame cost the same order as the full-screen live
@@ -62,6 +89,13 @@ export interface LiveTailProps {
62
89
  nowSec?: number;
63
90
  theme: Theme;
64
91
  caps: TermCapabilities;
92
+ /**
93
+ * Measured height of exactly the entries this frame renders, when the owner of the
94
+ * measurement (`use-scrollback-view.ts`) has it. It decides the anchor INSTEAD of the
95
+ * estimate - see `resolveTailAnchor`. `undefined` on every frame without a full
96
+ * measurement, which is every streaming frame: the estimate then decides, as before.
97
+ */
98
+ measuredRows?: number;
65
99
  }
66
100
  export interface TailEntry {
67
101
  entry: Entry;
@@ -76,4 +110,4 @@ export interface TailEntry {
76
110
  * separation row the transcript shows above the same card.
77
111
  */
78
112
  export declare function selectTailEntries(entries: readonly Entry[], maxEntries: number): readonly TailEntry[];
79
- export declare function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, }: LiveTailProps): React.ReactElement;
113
+ export declare function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, measuredRows, }: LiveTailProps): React.ReactElement;
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box } from 'ink';
3
3
  import { EntryView, nowSecFor } from '../Transcript.js';
4
+ import { estimateEntryRows } from '../layout/virtual-window.js';
4
5
  import { SCROLLBACK_LIMITS } from './limits.js';
5
6
  import { isExcluded } from './settled-boundary.js';
7
+ import { resolveTailAnchor } from './tail-anchor.js';
6
8
  /**
7
9
  * The last `maxEntries` printable entries, each with its predecessor.
8
10
  *
@@ -25,8 +27,17 @@ export function selectTailEntries(entries, maxEntries) {
25
27
  }
26
28
  return selected;
27
29
  }
28
- export function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, }) {
30
+ export function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, measuredRows, }) {
29
31
  const limited = Math.min(maxEntries, SCROLLBACK_LIMITS.liveTailMaxEntries);
30
32
  const selected = selectTailEntries(entries, limited);
31
- return (_jsx(Box, { flexDirection: "column", height: rows, width: cols, justifyContent: "flex-end", flexShrink: 0, overflow: "hidden", children: selected.map(({ entry, prev }) => (_jsx(EntryView, { entry: entry, prev: prev, expanded: expandedToolIds[entry.id] === true, thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, nowSec: nowSecFor(entry, nowSec), theme: theme, caps: caps }, entry.id))) }));
33
+ // `estimateEntryRows` is the estimate the transcript's virtual window already uses, so
34
+ // the anchor can never disagree with the height the rest of the interface plans for.
35
+ const estimateRows = (entry) => estimateEntryRows(entry, cols, density, expandedToolIds[entry.id] === true, thinkingVisible);
36
+ const anchor = resolveTailAnchor({
37
+ entries: selected.map((tailEntry) => tailEntry.entry),
38
+ budgetRows: rows,
39
+ rowOf: estimateRows,
40
+ measuredRows,
41
+ });
42
+ return (_jsx(Box, { flexDirection: "column", height: rows, width: cols, justifyContent: anchor === 'top' ? 'flex-start' : 'flex-end', flexShrink: 0, overflow: "hidden", children: selected.map(({ entry, prev }) => (_jsx(EntryView, { entry: entry, prev: prev, expanded: expandedToolIds[entry.id] === true, thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, nowSec: nowSecFor(entry, nowSec), theme: theme, caps: caps }, entry.id))) }));
32
43
  }
@@ -39,6 +39,22 @@ export declare const SCROLLBACK_LIMITS: {
39
39
  * blank band between the result and the running card.
40
40
  */
41
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.
56
+ */
57
+ readonly tailAnchorSlackRows: 2;
42
58
  /**
43
59
  * Rows the rail needs before the band will show it: `TODO_LIMITS.panelMinRows + 1`.
44
60
  *
@@ -39,6 +39,22 @@ export const SCROLLBACK_LIMITS = {
39
39
  * blank band between the result and the running card.
40
40
  */
41
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.
56
+ */
57
+ tailAnchorSlackRows: 2,
42
58
  /**
43
59
  * Rows the rail needs before the band will show it: `TODO_LIMITS.panelMinRows + 1`.
44
60
  *
@@ -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. */