@aragon-agent/cli 0.6.32 → 0.6.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -901,6 +901,10 @@ async function runInteractive(flags, extras = {}) {
901
901
  const { writer } = scrollbackBridge;
902
902
  setScrollbackStatsProvider(() => ({
903
903
  ...writer.scrollbackStats(), regionRows: writer.regionRows(),
904
+ // The canvas top is already published by `anchorStats()`; `/perf` needs it on the
905
+ // mode line to explain a region that does not start on row 0 (tui-full-height-
906
+ // live-region §5.8). Read per call, never cached: `/perf` shows THIS frame.
907
+ anchorTop: writer.anchorStats().top,
904
908
  }));
905
909
  }
906
910
  // The observer only serves the full-screen scrollbar geometry; native has none.
@@ -59,14 +59,41 @@ export interface PerfSnapshot {
59
59
  */
60
60
  export interface BandPerf {
61
61
  reason: 'visible' | 'empty' | 'disabled' | 'overlay' | 'narrow' | 'short';
62
+ /**
63
+ * The band's height. In the full-height region this IS the pool
64
+ * (`tui-full-height-live-region` D5), which is why `/perf` prints one `pool=` number
65
+ * rather than two fields that can only ever be equal.
66
+ */
67
+ bandRows: number;
62
68
  railRows: number;
63
69
  railCols: number;
64
70
  teamRows: number;
71
+ /** The tail column: the rows between the roster and the composer. */
65
72
  previewRows: number;
66
73
  contentCols: number;
67
74
  previewCols: number;
68
75
  heldRailRows: number;
69
76
  heldTeamRows: number;
77
+ /**
78
+ * The tail window: first visible entry index and how many entries are in it.
79
+ *
80
+ * The ONLY number that makes "what is on screen" and "what has been printed"
81
+ * comparable. A long answer can push earlier entries out of sight while they are
82
+ * still inside the window, so they are neither visible nor printed yet; the readout
83
+ * turns that disclosed gap (known limitation 5) into a number instead of a guess.
84
+ */
85
+ windowStart: number;
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;
70
97
  }
71
98
  /**
72
99
  * Why the right-edge scrollbar is or is not usable, in one record
@@ -136,8 +163,14 @@ export interface ScrollbackPerf {
136
163
  anchorCanvasRows: number;
137
164
  /** Content rows inside that canvas — how much of the conversation is on screen. */
138
165
  anchorHeldRows: number;
139
- /** Blank rows above the content. Non-zero is expected on a short conversation. */
166
+ /** Blank rows above the content. Zero by construction in the full-height region. */
140
167
  anchorGapRows: number;
168
+ /**
169
+ * Canvas top row (0-based). 0 means the canvas starts on the terminal's first row and
170
+ * is the direct answer to "why does the app start halfway down the screen" - the one
171
+ * number that used to be missing from this block.
172
+ */
173
+ anchorTop: number;
141
174
  /** Rows this session pushed into the terminal's own scrollback. */
142
175
  anchorScrollRows: number;
143
176
  /** Times the geometry was dropped on purpose (column change, shrink, geometry lost). */
@@ -136,8 +136,10 @@ function formatBandLine(band) {
136
136
  if (!band)
137
137
  return 'band n/a (full-screen layout)';
138
138
  return `band rail=${band.railRows}x${band.railCols} team=${band.teamRows} ` +
139
- `preview=${band.previewRows} cols=${band.contentCols}/${band.previewCols} ` +
140
- `reason=${band.reason} held=${band.heldRailRows}/${band.heldTeamRows}`;
139
+ `tail=${band.previewRows} pool=${band.bandRows} ` +
140
+ `cols=${band.contentCols}/${band.previewCols} ` +
141
+ `reason=${band.reason} held=${band.heldRailRows}/${band.heldTeamRows} ` +
142
+ `window=${band.windowStart}/${band.windowEntries} head=${band.headRows}`;
141
143
  }
142
144
  /**
143
145
  * Format a snapshot as the seven-line block documented in §5.4 (plus the scrollbar
@@ -149,6 +151,11 @@ export function formatPerfReport(snap) {
149
151
  const eco = snap.rung > 0 ? ' - eco' : '';
150
152
  const governor = snap.governorEnabled ? '' : ' - governor off';
151
153
  const native = snap.mode === 'native';
154
+ // The canvas top lives in the writer's counters, not in the snapshot: it is the direct
155
+ // explanation for "the app starts halfway down", so it belongs on the mode line next
156
+ // to the region height. `n/a` rather than 0 when no writer is attached, because 0 is a
157
+ // real and healthy answer.
158
+ const anchor = native ? scrollbackStats?.() ?? null : null;
152
159
  const scrollbar = native
153
160
  ? ['scrollbar n/a (native scroll)']
154
161
  : snap.scrollbar ? [formatScrollbarLine(snap.scrollbar)] : [];
@@ -163,7 +170,8 @@ export function formatPerfReport(snap) {
163
170
  `hl ${caches.highlightEntries} (${mb(caches.highlightBytes)}) - ` +
164
171
  `lines ${caches.lineEntries}`,
165
172
  native
166
- ? `mode native - live region ${snap.viewportRows} rows`
173
+ ? `mode native - live region ${snap.viewportRows} rows - ` +
174
+ `top ${anchor ? anchor.anchorTop : 'n/a'}`
167
175
  : `mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
168
176
  formatBandLine(snap.band),
169
177
  formatWriterLine(),
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) {
@@ -2293,9 +2311,17 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2293
2311
  pending: state.pendingSteering, columns: baseRail.contentCols, terminalRows: rows,
2294
2312
  availableRows: Math.max(0, viewportBudget - composerBaseRows - 1), paused: !running,
2295
2313
  }), [state.pendingSteering, baseRail.contentCols, rows, viewportBudget, composerBaseRows, running]);
2314
+ // THE POOL, NOT THE VIEWPORT MINUS A QUEUE THAT IS NOT DRAWN
2315
+ // (tui-full-height-live-region RV-P1-3). `QueuePanel` only mounts in the full-screen
2316
+ // layout, so native mode used to reserve rows for a card nobody paints. And it MUST be
2317
+ // `viewportBudget` rather than `nativeBudget.band.*`: `viewportRows === remaining` by
2318
+ // construction, and reading the band back here would close a loop with `teamRows`
2319
+ // (AC-34, asserted textually by `live-band.test.ts`).
2296
2320
  const teamLayout = buildTeamPanelLayout({
2297
2321
  snapshot: state.team, terminalRows: rows,
2298
- availableRows: Math.max(0, viewportBudget - composerBaseRows - queueLayout.rows - 1),
2322
+ availableRows: native
2323
+ ? Math.max(0, viewportBudget - 1)
2324
+ : Math.max(0, viewportBudget - composerBaseRows - queueLayout.rows - 1),
2299
2325
  });
2300
2326
  const railLayout = buildTodoRailLayout({
2301
2327
  cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
@@ -2315,14 +2341,14 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2315
2341
  // an inert hook is what keeps `scrollMode: 'app'` byte-identical (I-S9).
2316
2342
  const viewCols = native ? Math.max(1, cols - 1) : cols;
2317
2343
  const header = (_jsx(Header, { columns: viewCols, statusExpanded: statusExpanded, scrolledLines: scrolledLines, overlayOpen: overlay !== null, cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: pickHeaderVariant(cols), theme: theme, caps: caps }));
2318
- // The wordmark + getting-started card now live INSIDE the viewport, as its
2319
- // first block of content, so they scroll away instead of being yanked out of
2320
- // the header the moment the first message lands.
2321
- const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(native ? Math.max(0, rows - 8) : viewportBudget, native ? viewCols : contentCols), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: native ? Math.max(0, rows - 8) : viewportBudget, theme: theme, caps: caps })) : null;
2344
+ // The header and the opener are SLOTS OF THE SHELL, not printed bytes (D3/D4): at
2345
+ // `H = rows - 1` a printed row is pushed off the screen by the very next frame, so
2346
+ // `printBanner()` was a way of making the banner invisible. Neither is passed here.
2347
+ // The opener node is built below, because its height now comes from the native band.
2322
2348
  const scrollbackView = useScrollbackView({
2323
2349
  enabled: native, bridge: scrollback, entries: state.entries, runActive: running,
2324
2350
  theme, caps, density: cfg.density, thinkingVisible: state.thinkingVisible,
2325
- reducedMotion, rows, cols, headerNode: header, openerNode: opener,
2351
+ reducedMotion, rows, cols,
2326
2352
  overlayOpen: overlay !== null, draftRows, popupRows: overlay ? 0 : popupRows,
2327
2353
  statusExpanded, forceInactive: native && guardTripped,
2328
2354
  // The band's inputs. `teamRowsNatural` is the roster layout computed above, so the
@@ -2331,6 +2357,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2331
2357
  itemCount: state.todos?.items.length ?? 0,
2332
2358
  panelEnabled: cfg.todo.panel,
2333
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,
2334
2364
  });
2335
2365
  scrollbackViewRef.current = scrollbackView;
2336
2366
  // The two hooks `cli.tsx` calls from OUTSIDE React: the exit summary (every exit
@@ -2352,6 +2382,14 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2352
2382
  }, [native, scrollback, controller, glyphs]);
2353
2383
  useEffect(() => { setGuardTripped(false); }, [rows, cols]);
2354
2384
  const nativeBudget = native ? scrollbackView.budget : null;
2385
+ // THE EMPTY-SESSION CARD, ONE NODE FOR BOTH MODES (D4 / RV-P0-1). The wordmark and the
2386
+ // getting-started card are the first block of content in `app` mode (they scroll away
2387
+ // with the transcript) and the shell's `opener` slot in native mode. Its height used to
2388
+ // be a `rows - 8` guess in native mode; the honest input is the band's own pool, which
2389
+ // is why this node is built after `nativeBudget`.
2390
+ const openerRows = nativeBudget ? nativeBudget.band.bandRows : viewportBudget;
2391
+ const openerCols = nativeBudget ? nativeBudget.previewCols : contentCols;
2392
+ const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(openerRows, openerCols), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: openerRows, theme: theme, caps: caps })) : null;
2355
2393
  const overlayMaxRows = nativeBudget ? nativeBudget.overlayRows : viewportBudget;
2356
2394
  const overlayNode = overlay === 'queue' ? (_jsx(QueueOverlay, { pending: state.pendingSteering, cols: viewCols, maxRows: overlayMaxRows, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: viewCols, rows: helpRows(theme, caps, native), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { isActive: !tooSmall, registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: viewCols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { isActive: !tooSmall, initial: {
2357
2395
  provider: cfg.provider,
@@ -2415,7 +2453,17 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2415
2453
  // `null` outside native mode, and `/perf` prints `n/a` for it: a band row of
2416
2454
  // zeroes in full-screen mode would read like a panel that failed to show.
2417
2455
  band: nativeBudget
2418
- ? { ...nativeBudget.band, contentCols: nativeBudget.contentCols }
2456
+ ? {
2457
+ ...nativeBudget.band,
2458
+ contentCols: nativeBudget.contentCols,
2459
+ // The tail window, joined here because it belongs to the VIEW pipeline rather
2460
+ // than to the band: `window=<start>/<n>` is what makes the visible-vs-printed
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.
2463
+ windowStart: scrollbackView.windowStart,
2464
+ windowEntries: scrollbackView.tailEntries.length,
2465
+ headRows: scrollbackView.headRows,
2466
+ }
2419
2467
  : null,
2420
2468
  // A getter: the bridge's `frameReady` and geometry change outside React renders.
2421
2469
  get scrollbar() {
@@ -2552,11 +2600,15 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2552
2600
  }
2553
2601
  : {}) }));
2554
2602
  if (nativeBudget) {
2555
- return (_jsx(ScrollbackShell, { budget: nativeBudget, overlay: overlayNode, liveTailLines: scrollbackView.liveTailLines,
2556
- // Real components for the unfinished tail (I-7); the plain rows above stay as
2557
- // the fallback for a frame with no preview rows.
2603
+ // THE OPENER IS MOUNTED ON THE SHELL'S TOP-ALIGNED SLOT (D4 / RV-P0-1): it is sized
2604
+ // from the band's pool above, and bottom-aligning it would reproduce the reported
2605
+ // "blank upper half" INSIDE the region, where the canvas `gap` assertion cannot see it.
2606
+ return (_jsx(ScrollbackShell, { budget: nativeBudget, header: header, overlay: overlayNode, liveTailLines: scrollbackView.liveTailLines, ...(opener ? { opener } : {}),
2607
+ // Real components for the tail window (I-7); the plain rows above stay as the
2608
+ // fallback for a frame with no preview rows. The WHOLE list is passed: the window
2609
+ // belongs to `tail-window.ts`, and `prev` must come from the same list.
2558
2610
  tail: nativeBudget.band.previewRows > 0
2559
- ? (_jsx(LiveTail, { unsettled: scrollbackView.unsettledEntries, 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 }))
2560
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 }));
2561
2613
  }
2562
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 }));
@@ -1,14 +1,17 @@
1
1
  /**
2
- * ScrollbackShell — the bounded LIVE REGION of native-scroll mode
3
- * (tui-native-terminal-scroll §4.6).
2
+ * ScrollbackShell — the LIVE REGION of native-scroll mode
3
+ * (tui-native-terminal-scroll §4.6, tui-full-height-live-region D3/D4).
4
4
  *
5
- * The full-screen `AppShell` fills the whole terminal; this shell is only as tall as
6
- * `budget.liveRows` (the root box height IS that number: giving it the frame height
7
- * would make the "small region" a whole screen again, and Ink would then repaint a
8
- * screenful per frame). Everything settled has already been printed into the
9
- * terminal's own scrollback above it.
5
+ * THE REGION IS THE SCREEN. The root box is `budget.liveRows` tall, and that number is
6
+ * `rows - 1` - the whole area above the writer's parked row. It used to be only as tall
7
+ * as the panel band asked for, and every row it did not ask for became the canvas
8
+ * `gap`: blank rows ABOVE the content, which is what a user reports as "the app starts
9
+ * halfway down the terminal" and as "a big blank between the result and the running
10
+ * card". `H = rows - 1` is also the ONLY height that both fills the screen and keeps Ink
11
+ * off its `clearTerminal` branch (that branch fires at `outputHeight >= rows` and
12
+ * contains `ESC[3J`, which erases the user's scrollback).
10
13
  *
11
- * Top to bottom: [overlay | panel band][composer][status][details].
14
+ * Top to bottom: [header][overlay | panel band][composer][status][details].
12
15
  *
13
16
  * THE PANEL BAND IS THE PART NATIVE MODE WAS MISSING
14
17
  * (release-gate-and-native-panels §6.1). A fixed frame can put a rail beside the
@@ -18,28 +21,60 @@
18
21
  * +---------------------------------------------+-----------------+
19
22
  * | team roster (height = band.teamRows) | TODO rail |
20
23
  * +---------------------------------------------+ (height = |
21
- * | unfinished tail (height = band.previewRows) | band.railRows) |
24
+ * | tail window (height = band.previewRows) | band.railRows) |
22
25
  * +---------------------------------------------+-----------------+
23
26
  *
24
27
  * Every box in it has an explicit `height` and `width` plus `overflow="hidden"`, and
25
28
  * that is a correctness requirement rather than tidiness: a box that grows with its
26
29
  * content would make the region taller than `budget.liveRows`, and a frame as tall as
27
- * the terminal makes Ink erase the user's scrollback (I-4).
30
+ * the terminal makes Ink erase the user's scrollback.
31
+ *
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):
34
+ * - `header` is the fixed 1-row slot at the very top. It is NOT printed into the
35
+ * scrollback any more: at `H = rows - 1` any printed row is pushed off the screen by
36
+ * the next frame, so "print the header" would mean "the header is never visible".
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`).
28
49
  *
29
50
  * `liveTailLines` STAYS. It is the plain-text fallback for the frames where the real
30
51
  * tail is not mounted (and the floor of the measurement path); `tail` wins when both
31
- * 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.
32
56
  */
33
57
  import React from 'react';
34
58
  import type { Theme } from './theme.js';
35
59
  import type { ScrollbackBudget } from './scrollback/budget.js';
36
60
  export interface ScrollbackShellProps {
37
61
  budget: ScrollbackBudget;
62
+ /**
63
+ * REQUIRED: the one-row slot at the top of the region, `budget.headerRows` tall.
64
+ * Required rather than optional for the reason `budget` and `band` are - a frame
65
+ * that forgot it would render a header-less screen with nothing to catch it.
66
+ */
67
+ header: React.ReactNode;
38
68
  /** Shown instead of the band when an overlay is open. */
39
69
  overlay: React.ReactNode | null;
40
70
  /** Plain preview rows: the fallback when `tail` is absent (and what `/perf` counts). */
41
71
  liveTailLines: readonly string[];
42
- /** Real components for the unfinished tail (`LiveTail`). */
72
+ /**
73
+ * The empty-session card. Mutually exclusive with `tail`, TOP-aligned like the rest of
74
+ * the band's preview box (RV-P0-1).
75
+ */
76
+ opener?: React.ReactNode;
77
+ /** Real components for the tail window (`LiveTail`). */
43
78
  tail?: React.ReactNode;
44
79
  /** The subagent roster, drawn at the top of the band's left column. */
45
80
  team?: React.ReactNode;
@@ -51,4 +86,4 @@ export interface ScrollbackShellProps {
51
86
  placeholder: React.ReactNode;
52
87
  theme: Theme;
53
88
  }
54
- export declare function ScrollbackShell({ budget, overlay, liveTailLines, tail, team, rail, composer, status, details, placeholder, theme, }: ScrollbackShellProps): React.ReactElement;
89
+ export declare function ScrollbackShell({ budget, header, overlay, liveTailLines, opener, tail, team, rail, composer, status, details, placeholder, theme, }: ScrollbackShellProps): React.ReactElement;
@@ -1,15 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- export function ScrollbackShell({ budget, overlay, liveTailLines, tail, team, rail, composer, status, details, placeholder, theme, }) {
3
+ export function ScrollbackShell({ budget, header, overlay, liveTailLines, opener, tail, team, rail, composer, status, details, placeholder, theme, }) {
4
4
  const width = budget.contentCols;
5
5
  if (budget.inactive) {
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: 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.
12
+ const leftColumn = opener ?? tail;
9
13
  const tailLines = liveTailLines.slice(-budget.liveTailRows);
10
14
  // The overlay and the band are alternatives, not siblings: the band reports
11
15
  // `bandRows = remaining` for an overlay frame, so drawing both would double the
12
16
  // region's height and take the frame to the terminal height (the `ESC[3J` case).
13
- 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-end", overflow: "hidden", children: tail ?? 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;
14
- return (_jsxs(Box, { flexDirection: "column", height: budget.liveRows, width: width, overflow: "hidden", flexShrink: 0, children: [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 }))] }));
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;
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 }))] }));
15
19
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * LiveTail - the band's UNFINISHED tail, rendered with the real entry components
3
- * (release-gate-and-native-panels §6.5).
2
+ * LiveTail - the band's TAIL WINDOW, rendered with the real entry components
3
+ * (release-gate-and-native-panels §6.5, tui-full-height-live-region D6).
4
4
  *
5
5
  * WHY THIS FILE EXISTS. Native scroll mode cannot print an entry until it settles, so
6
6
  * the live region used to show `buildLivePreview`'s plain-text projection - and for a
@@ -9,6 +9,15 @@
9
9
  * it, which is why the region's content type changed from `readonly string[]` to a
10
10
  * fixed-height box that can hold components.
11
11
  *
12
+ * IT SHOWS THE CONVERSATION, NOT JUST THE UNFINISHED PART (D6/D7). The input used to be
13
+ * `entries.slice(settledCount)` - the entries the reducer had not finished writing -
14
+ * because everything settled was printed into the scrollback immediately. Now the region
15
+ * is the whole screen, `H = rows - 1`, so a printed row is pushed off the top by the next
16
+ * frame; the region therefore holds the last `SCROLLBACK_LIMITS.liveTailMaxEntries`
17
+ * PRINTABLE entries, settled or not, and an entry is printed when it LEAVES that window.
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 when it overflows.
20
+ *
12
21
  * IT RENDERS `EntryView` AND NOTHING ELSE. The mapping from an entry to its component
13
22
  * (`assistant` / `tool` / `team` / `todo` / service cards, notice frames, the `null`
14
23
  * cases) lives in `Transcript.tsx` exactly once, together with `EntryFrame`'s gutter,
@@ -17,10 +26,39 @@
17
26
  * left rail glyph and the transcript's would keep it, and the two halves of one screen
18
27
  * would disagree about the same entry.
19
28
  *
20
- * `justifyContent="flex-end"` PLUS `overflow="hidden"` IS CORRECTNESS, NOT STYLE: when
21
- * the content is taller than the budget the rows that get clipped are the OLDEST ones,
22
- * so the newest line always sits against the composer - the same promise
23
- * `scrollback/canvas.ts` makes about the live tail.
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.
24
62
  *
25
63
  * BOUNDED: at most `SCROLLBACK_LIMITS.liveTailMaxEntries` component instances per frame
26
64
  * (I-10). That is what keeps the per-frame cost the same order as the full-screen live
@@ -32,8 +70,12 @@ import type { TermCapabilities } from '../capabilities.js';
32
70
  import type { DensityMode } from '../density.js';
33
71
  import type { Theme } from '../theme.js';
34
72
  export interface LiveTailProps {
35
- /** `entries.slice(settledCount).filter(isExcluded === false)`: the same input as `buildLivePreview`. */
36
- unsettled: readonly Entry[];
73
+ /**
74
+ * The whole entry list (settled and unfinished alike); the window is taken here.
75
+ * Callers must not pre-slice it: `prev` has to come from the same list the transcript
76
+ * draws, or the top card of the band loses the separator row it has upstairs.
77
+ */
78
+ entries: readonly Entry[];
37
79
  /** Render only the last N entries; the box clips the older ones from the top. */
38
80
  maxEntries: number;
39
81
  /** Fixed height: the root box's `height`. The content never decides it (I-4). */
@@ -47,18 +89,25 @@ export interface LiveTailProps {
47
89
  nowSec?: number;
48
90
  theme: Theme;
49
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;
50
99
  }
51
100
  export interface TailEntry {
52
101
  entry: Entry;
53
- /** The entry before it in the FULL list: `EntryView` uses it to decide the separator. */
102
+ /** The entry before it in the passed list: `EntryView` decides the separator from it. */
54
103
  prev: Entry | undefined;
55
104
  }
56
105
  /**
57
- * The last `maxEntries` entries, each with its predecessor.
106
+ * The last `maxEntries` printable entries, each with its predecessor.
58
107
  *
59
- * `prev` must come from the COMPLETE unsettled list rather than from the truncated
60
- * slice, or the first rendered entry would look like the head of the conversation and
61
- * drop the separation row the transcript shows above the same card.
108
+ * `prev` must come from the same COMPLETE list rather than from the truncated slice, or
109
+ * the first rendered entry would look like the head of the conversation and drop the
110
+ * separation row the transcript shows above the same card.
62
111
  */
63
- export declare function selectTailEntries(unsettled: readonly Entry[], maxEntries: number): readonly TailEntry[];
64
- export declare function LiveTail({ unsettled, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, }: LiveTailProps): React.ReactElement;
112
+ export declare function selectTailEntries(entries: readonly Entry[], maxEntries: number): readonly TailEntry[];
113
+ export declare function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, measuredRows, }: LiveTailProps): React.ReactElement;
@@ -1,27 +1,43 @@
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';
6
+ import { isExcluded } from './settled-boundary.js';
7
+ import { resolveTailAnchor } from './tail-anchor.js';
5
8
  /**
6
- * The last `maxEntries` entries, each with its predecessor.
9
+ * The last `maxEntries` printable entries, each with its predecessor.
7
10
  *
8
- * `prev` must come from the COMPLETE unsettled list rather than from the truncated
9
- * slice, or the first rendered entry would look like the head of the conversation and
10
- * drop the separation row the transcript shows above the same card.
11
+ * `prev` must come from the same COMPLETE list rather than from the truncated slice, or
12
+ * the first rendered entry would look like the head of the conversation and drop the
13
+ * separation row the transcript shows above the same card.
11
14
  */
12
- export function selectTailEntries(unsettled, maxEntries) {
15
+ export function selectTailEntries(entries, maxEntries) {
16
+ const printable = entries.filter((entry) => !isExcluded(entry));
13
17
  const limit = Number.isFinite(maxEntries) ? Math.max(0, Math.floor(maxEntries)) : 0;
14
18
  if (limit === 0)
15
19
  return [];
16
- const start = Math.max(0, unsettled.length - limit);
20
+ const start = Math.max(0, printable.length - limit);
17
21
  const selected = [];
18
- for (let index = start; index < unsettled.length; index += 1) {
19
- selected.push({ entry: unsettled[index], prev: index > 0 ? unsettled[index - 1] : undefined });
22
+ for (let index = start; index < printable.length; index += 1) {
23
+ selected.push({
24
+ entry: printable[index],
25
+ prev: index > 0 ? printable[index - 1] : undefined,
26
+ });
20
27
  }
21
28
  return selected;
22
29
  }
23
- export function LiveTail({ unsettled, 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, }) {
24
31
  const limited = Math.min(maxEntries, SCROLLBACK_LIMITS.liveTailMaxEntries);
25
- const selected = selectTailEntries(unsettled, limited);
26
- 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))) }));
32
+ const selected = selectTailEntries(entries, limited);
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))) }));
27
43
  }