@aragon-agent/cli 0.6.32 → 0.6.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -121,6 +121,25 @@ were written.
121
121
 
122
122
  ### Fixed
123
123
 
124
+ - **The native-scroll screen is filled edge to edge, with no blank band anywhere.**
125
+ Three complaints, one cause: the live region was only as tall as the panel band
126
+ asked for, and every row it did not ask for became the canvas `gap` - blank rows
127
+ drawn *above* the content. So the app appeared to start halfway down the terminal,
128
+ a long blank band sat between a finished result and the card that was still running,
129
+ and the `TODO` rail stopped six rows or so above the input box. The region is now
130
+ the whole screen (`rows - 1`, the same height the full-screen layout uses): the
131
+ header is its first row, the input box and status line are its last two, the band
132
+ takes every row in between (`bandRows = pool`), so the rail spans the full height
133
+ above the composer and the tail column ends against it. The `gap` is 0 by
134
+ construction, not by tuning, and the scrollback writer is unchanged - `[gap][held]
135
+ [region]` collapses to `[region]` at that height. Two consequences worth knowing:
136
+ the region now shows the last ten entries of the conversation instead of only the
137
+ unfinished ones, and an entry is printed into your scrollback when it leaves that
138
+ window (so nothing is ever in both places at once); `/clear`, `--resume` and exit
139
+ flush the window first, which is why nothing visible is lost. The empty-session card
140
+ is drawn against the header rather than the input box, and `/perf` grew the three
141
+ numbers that make this self-checking: `top <n>` on the `mode native` line, plus
142
+ `pool=` and `window=<start>/<n>` on the `band:` line.
124
143
  - **The TODO rail and the subagent roster are back in the default (native) scroll
125
144
  mode.** Printing the conversation into the terminal's own scrollback took the two
126
145
  panels with it: the rail lived in the full-screen layout's middle band and the
package/README.md CHANGED
@@ -782,11 +782,12 @@ near-identical added ones.
782
782
  ### Native scroll mode (default)
783
783
 
784
784
  The conversation lives in **your terminal's own scrollback**, exactly like the output
785
- of any other command. Finished messages are printed once, above a small *live
786
- region* at the bottom of the screen (the streaming preview, the input box and the
787
- status line). Scrolling, selecting and searching are your terminal's job, on every
788
- platform: PowerShell, `cmd`, Windows Terminal, VS Code, `bash`, `zsh`, `tmux`,
789
- iTerm2, Terminal.app, gnome-terminal and friends.
785
+ of any other command. The *live region* fills the screen - header on the first row,
786
+ input box and status line on the last two, the tail of the conversation in between -
787
+ and an entry is printed into the scrollback once it leaves that region. Scrolling,
788
+ selecting and searching are your terminal's job, on every platform: PowerShell,
789
+ `cmd`, Windows Terminal, VS Code, `bash`, `zsh`, `tmux`, iTerm2, Terminal.app,
790
+ gnome-terminal and friends.
790
791
 
791
792
  - **Wheel, scrollbar, `Shift+PgUp` / `Shift+PgDn`, touchpad inertia** — the terminal's.
792
793
  `aragon` does not enter the alternate screen and does not turn on mouse reporting,
@@ -801,11 +802,15 @@ iTerm2, Terminal.app, gnome-terminal and friends.
801
802
  time, usage, and the `aragon --resume <id>` line) instead of replaying the
802
803
  transcript, because the transcript is already in your scrollback — `exitTranscript`
803
804
  is ignored in this mode.
804
- - **While a reply streams**, the last few lines show in the live region; when the
805
- message is final it is printed in its formatted shape and the preview disappears
806
- (a small, intentional "settle" jump). Scrolling up to read earlier output while a
807
- reply streams is safe; how the viewport behaves when new output arrives is your
808
- terminal's own "scroll on output" setting.
805
+ - **While a reply streams**, it is drawn in the live region with the real cards (a
806
+ running `tool` or `team` card looks exactly like the full-screen one). The region
807
+ holds the last ten entries of the conversation, settled or not, and an entry is
808
+ printed into your scrollback when it LEAVES that window - so what is on the screen
809
+ and what you scroll up to are two disjoint halves of one conversation, never the
810
+ same paragraph twice. Scrolling up to read earlier output while a reply streams is
811
+ safe; how the viewport behaves when new output arrives is your terminal's own
812
+ "scroll on output" setting. A message you have just sent is therefore on the screen
813
+ only: it reaches the scrollback when ten newer entries push it out, or when you exit.
809
814
  - **Printed rows are final once they leave the screen.** A row that has scrolled into
810
815
  your scrollback is never rewritten, and it is wrapped to the width at which it was
811
816
  printed, so a later window resize does not re-wrap it. The rows still *on* the screen
@@ -814,15 +819,16 @@ iTerm2, Terminal.app, gnome-terminal and friends.
814
819
  (thinking) and `/theme` affect only *new* messages, and `Ctrl+O` prints an
815
820
  expanded copy of a tool card instead of expanding the old one. `/clear` prints a
816
821
  `---- conversation cleared ----` separator; it cannot erase scrollback.
817
- - **The reply always touches the input box.** The input box and status line sit on the
818
- **last two rows of the terminal** at all times, and the newest printed line sits
819
- directly above them: the blank space of a conversation that has not filled the screen
820
- is *above the conversation*, never between the reply and the composer. Three things
821
- are still true. A terminal that cannot be measured (`/perf` shows `anchor bottom via
822
- fill`, or `none` when the probe is unavailable) is filled to the bottom once at
823
- startup, which scrolls your screen up by a page — that one-off flip is the price of
824
- pinning without a cursor query. Making the window narrower or shorter drops the
825
- geometry until the next measurement (a couple of hundred milliseconds) instead of
822
+ - **The interface fills the terminal.** The header is the first row of the screen, the
823
+ input box and status line are the last two, and everything between them belongs to
824
+ the conversation: the newest line always touches the input box, so the blank space of
825
+ a conversation that has not filled the screen sits at the *top of the region* - never
826
+ between the reply and the composer, and never as a band above the app itself. Three
827
+ things are still true. A terminal that cannot be measured (`/perf` shows `anchor
828
+ bottom via fill`, or `none` when the probe is unavailable) is filled to the bottom
829
+ once at startup, which scrolls your screen up by a page — that one-off flip is the
830
+ price of pinning without a cursor query. Making the window narrower or shorter drops
831
+ the geometry until the next measurement (a couple of hundred milliseconds) instead of
826
832
  guessing: new output parks under the last printed line until the composer pins itself
827
833
  again, and old content is neither re-wrapped nor redrawn. `Ctrl+G`, `/help` and the
828
834
  model picker open as overlays inside the live region, and they take the whole top of
@@ -831,21 +837,28 @@ iTerm2, Terminal.app, gnome-terminal and friends.
831
837
  puts a `TODO d/t` column on the right of the band - markers, index, the running
832
838
  item's `activeForm`, an overflow marker and the gauge - and a running dispatch puts
833
839
  the `team` roster above the reply, one row per subagent with its activity and
834
- elapsed time. The unfinished tail underneath them is drawn with the same cards the
835
- full-screen mode uses, so a running tool or team card looks identical in both modes.
836
- The band is bounded (the preview shares the terminal height with the composer, the
837
- status line and the rail) and the printed conversation above it always keeps the
838
- full width. Below 76 columns there is no room for the rail and the status line's
839
- `todo` chip takes over; `/perf`'s `band:` line says which of the six reasons applied.
840
+ elapsed time. The tail underneath them is drawn with the same cards the full-screen
841
+ mode uses, so a running tool or team card looks identical in both modes. The band
842
+ takes **every row between the header and the composer**: the rail spans the full
843
+ height above the input box instead of stopping partway down, the tail column ends
844
+ against the composer, and the printed conversation above always keeps the full width.
845
+ Below 76 columns there is no room for the rail and the status line's `todo` chip
846
+ takes over; `/perf`'s `band:` line says which of the six reasons applied.
840
847
  - **If something looks wrong**, `/perf` has a `scrollback` line (`guardHits`,
841
848
  `clippedRows`, `fallbacks`, and the geometry: `region`/`canvas`/`content`/`gap` rows,
842
849
  `anchor bottom via probe|fill|none`, `scrolled` rows, `anchor resets`, `reprobes`).
843
- A `band:` line reports the panel band - `rail=<rows>x<cols> team=<rows>`
844
- `preview=<rows> cols=<contentCols>/<previewCols> reason=<visible|empty|disabled|`
845
- `narrow|short|overlay> held=<rail>/<team>` - which is the only place that says why a
846
- panel is not on screen.
847
- `gap` is the blank space above the content, `canvas` is the canvas height including
848
- the live region, and `scrolled` counts the rows that have moved into your scrollback.
850
+ The `mode native` line carries `top <n>`, the canvas's first screen row. A `band:`
851
+ line reports the panel band - `rail=<rows>x<cols> team=<rows> tail=<rows>
852
+ pool=<rows> cols=<contentCols>/<previewCols> reason=<visible|empty|disabled|`
853
+ `narrow|short|overlay> held=<rail>/<team> window=<start>/<n>` - which is the only
854
+ place that says why a panel is not on screen.
855
+ `gap` is the blank space above the content and is **always 0** now (the region is the
856
+ whole screen), `canvas` is the canvas height including the live region, `pool` is the
857
+ band's height (the rows between the header and the composer) and `window=<start>/<n>`
858
+ is the tail window: the first entry on screen and how many entries it holds. That
859
+ number is the honest answer to "why is a message I can see not in my scrollback yet"
860
+ - the region shows ten entries, and an entry is printed when it leaves them.
861
+ `scrolled` counts the rows that have moved into your scrollback.
849
862
  The one-step escape hatch is `aragon config set scrollMode app`, or `--scroll-mode
850
863
  app` for a single run.
851
864
 
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,31 @@ 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;
70
87
  }
71
88
  /**
72
89
  * Why the right-edge scrollbar is or is not usable, in one record
@@ -136,8 +153,14 @@ export interface ScrollbackPerf {
136
153
  anchorCanvasRows: number;
137
154
  /** Content rows inside that canvas — how much of the conversation is on screen. */
138
155
  anchorHeldRows: number;
139
- /** Blank rows above the content. Non-zero is expected on a short conversation. */
156
+ /** Blank rows above the content. Zero by construction in the full-height region. */
140
157
  anchorGapRows: number;
158
+ /**
159
+ * Canvas top row (0-based). 0 means the canvas starts on the terminal's first row and
160
+ * is the direct answer to "why does the app start halfway down the screen" - the one
161
+ * number that used to be missing from this block.
162
+ */
163
+ anchorTop: number;
141
164
  /** Rows this session pushed into the terminal's own scrollback. */
142
165
  anchorScrollRows: number;
143
166
  /** 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}`;
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
@@ -2293,9 +2293,17 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2293
2293
  pending: state.pendingSteering, columns: baseRail.contentCols, terminalRows: rows,
2294
2294
  availableRows: Math.max(0, viewportBudget - composerBaseRows - 1), paused: !running,
2295
2295
  }), [state.pendingSteering, baseRail.contentCols, rows, viewportBudget, composerBaseRows, running]);
2296
+ // THE POOL, NOT THE VIEWPORT MINUS A QUEUE THAT IS NOT DRAWN
2297
+ // (tui-full-height-live-region RV-P1-3). `QueuePanel` only mounts in the full-screen
2298
+ // layout, so native mode used to reserve rows for a card nobody paints. And it MUST be
2299
+ // `viewportBudget` rather than `nativeBudget.band.*`: `viewportRows === remaining` by
2300
+ // construction, and reading the band back here would close a loop with `teamRows`
2301
+ // (AC-34, asserted textually by `live-band.test.ts`).
2296
2302
  const teamLayout = buildTeamPanelLayout({
2297
2303
  snapshot: state.team, terminalRows: rows,
2298
- availableRows: Math.max(0, viewportBudget - composerBaseRows - queueLayout.rows - 1),
2304
+ availableRows: native
2305
+ ? Math.max(0, viewportBudget - 1)
2306
+ : Math.max(0, viewportBudget - composerBaseRows - queueLayout.rows - 1),
2299
2307
  });
2300
2308
  const railLayout = buildTodoRailLayout({
2301
2309
  cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
@@ -2315,14 +2323,14 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2315
2323
  // an inert hook is what keeps `scrollMode: 'app'` byte-identical (I-S9).
2316
2324
  const viewCols = native ? Math.max(1, cols - 1) : cols;
2317
2325
  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;
2326
+ // The header and the opener are SLOTS OF THE SHELL, not printed bytes (D3/D4): at
2327
+ // `H = rows - 1` a printed row is pushed off the screen by the very next frame, so
2328
+ // `printBanner()` was a way of making the banner invisible. Neither is passed here.
2329
+ // The opener node is built below, because its height now comes from the native band.
2322
2330
  const scrollbackView = useScrollbackView({
2323
2331
  enabled: native, bridge: scrollback, entries: state.entries, runActive: running,
2324
2332
  theme, caps, density: cfg.density, thinkingVisible: state.thinkingVisible,
2325
- reducedMotion, rows, cols, headerNode: header, openerNode: opener,
2333
+ reducedMotion, rows, cols,
2326
2334
  overlayOpen: overlay !== null, draftRows, popupRows: overlay ? 0 : popupRows,
2327
2335
  statusExpanded, forceInactive: native && guardTripped,
2328
2336
  // The band's inputs. `teamRowsNatural` is the roster layout computed above, so the
@@ -2352,6 +2360,14 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2352
2360
  }, [native, scrollback, controller, glyphs]);
2353
2361
  useEffect(() => { setGuardTripped(false); }, [rows, cols]);
2354
2362
  const nativeBudget = native ? scrollbackView.budget : null;
2363
+ // THE EMPTY-SESSION CARD, ONE NODE FOR BOTH MODES (D4 / RV-P0-1). The wordmark and the
2364
+ // getting-started card are the first block of content in `app` mode (they scroll away
2365
+ // with the transcript) and the shell's `opener` slot in native mode. Its height used to
2366
+ // be a `rows - 8` guess in native mode; the honest input is the band's own pool, which
2367
+ // is why this node is built after `nativeBudget`.
2368
+ const openerRows = nativeBudget ? nativeBudget.band.bandRows : viewportBudget;
2369
+ const openerCols = nativeBudget ? nativeBudget.previewCols : contentCols;
2370
+ const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(openerRows, openerCols), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: openerRows, theme: theme, caps: caps })) : null;
2355
2371
  const overlayMaxRows = nativeBudget ? nativeBudget.overlayRows : viewportBudget;
2356
2372
  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
2373
  provider: cfg.provider,
@@ -2415,7 +2431,15 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2415
2431
  // `null` outside native mode, and `/perf` prints `n/a` for it: a band row of
2416
2432
  // zeroes in full-screen mode would read like a panel that failed to show.
2417
2433
  band: nativeBudget
2418
- ? { ...nativeBudget.band, contentCols: nativeBudget.contentCols }
2434
+ ? {
2435
+ ...nativeBudget.band,
2436
+ contentCols: nativeBudget.contentCols,
2437
+ // The tail window, joined here because it belongs to the VIEW pipeline rather
2438
+ // than to the band: `window=<start>/<n>` is what makes the visible-vs-printed
2439
+ // gap observable (known limitation 5).
2440
+ windowStart: scrollbackView.windowStart,
2441
+ windowEntries: scrollbackView.tailEntries.length,
2442
+ }
2419
2443
  : null,
2420
2444
  // A getter: the bridge's `frameReady` and geometry change outside React renders.
2421
2445
  get scrollbar() {
@@ -2552,11 +2576,15 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2552
2576
  }
2553
2577
  : {}) }));
2554
2578
  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.
2579
+ // THE OPENER IS MOUNTED ON THE SHELL'S TOP-ALIGNED SLOT (D4 / RV-P0-1): it is sized
2580
+ // from the band's pool above, and bottom-aligning it would reproduce the reported
2581
+ // "blank upper half" INSIDE the region, where the canvas `gap` assertion cannot see it.
2582
+ return (_jsx(ScrollbackShell, { budget: nativeBudget, header: header, overlay: overlayNode, liveTailLines: scrollbackView.liveTailLines, ...(opener ? { opener } : {}),
2583
+ // Real components for the tail window (I-7); the plain rows above stay as the
2584
+ // fallback for a frame with no preview rows. The WHOLE list is passed: the window
2585
+ // belongs to `tail-window.ts`, and `prev` must come from the same list.
2558
2586
  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 }))
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 }))
2560
2588
  : 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
2589
  }
2562
2590
  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,13 +21,24 @@
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
+ * TWO SLOTS ARE NOT BOTTOM-ALIGNED, AND ONLY TWO (D3 / D4 / I-F7):
33
+ * - `header` is the fixed 1-row slot at the very top. It is NOT printed into the
34
+ * scrollback any more: at `H = rows - 1` any printed row is pushed off the screen by
35
+ * 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.
28
42
  *
29
43
  * `liveTailLines` STAYS. It is the plain-text fallback for the frames where the real
30
44
  * tail is not mounted (and the floor of the measurement path); `tail` wins when both
@@ -35,11 +49,22 @@ import type { Theme } from './theme.js';
35
49
  import type { ScrollbackBudget } from './scrollback/budget.js';
36
50
  export interface ScrollbackShellProps {
37
51
  budget: ScrollbackBudget;
52
+ /**
53
+ * REQUIRED: the one-row slot at the top of the region, `budget.headerRows` tall.
54
+ * Required rather than optional for the reason `budget` and `band` are - a frame
55
+ * that forgot it would render a header-less screen with nothing to catch it.
56
+ */
57
+ header: React.ReactNode;
38
58
  /** Shown instead of the band when an overlay is open. */
39
59
  overlay: React.ReactNode | null;
40
60
  /** Plain preview rows: the fallback when `tail` is absent (and what `/perf` counts). */
41
61
  liveTailLines: readonly string[];
42
- /** Real components for the unfinished tail (`LiveTail`). */
62
+ /**
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).
65
+ */
66
+ opener?: React.ReactNode;
67
+ /** Real components for the tail window (`LiveTail`). */
43
68
  tail?: React.ReactNode;
44
69
  /** The subagent roster, drawn at the top of the band's left column. */
45
70
  team?: React.ReactNode;
@@ -51,4 +76,4 @@ export interface ScrollbackShellProps {
51
76
  placeholder: React.ReactNode;
52
77
  theme: Theme;
53
78
  }
54
- export declare function ScrollbackShell({ budget, overlay, liveTailLines, tail, team, rail, composer, status, details, placeholder, theme, }: ScrollbackShellProps): React.ReactElement;
79
+ export declare function ScrollbackShell({ budget, header, overlay, liveTailLines, opener, tail, team, rail, composer, status, details, placeholder, theme, }: ScrollbackShellProps): React.ReactElement;
@@ -1,15 +1,18 @@
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: whichever is mounted decides the
10
+ // alignment of the box, so the two can never both be drawn and fight for the height.
11
+ const leftColumn = opener ?? tail;
9
12
  const tailLines = liveTailLines.slice(-budget.liveTailRows);
10
13
  // The overlay and the band are alternatives, not siblings: the band reports
11
14
  // `bandRows = remaining` for an overlay frame, so drawing both would double the
12
15
  // 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 }))] }));
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
+ 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
18
  }
@@ -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.
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,
@@ -20,7 +29,9 @@
20
29
  * `justifyContent="flex-end"` PLUS `overflow="hidden"` IS CORRECTNESS, NOT STYLE: when
21
30
  * the content is taller than the budget the rows that get clipped are the OLDEST ones,
22
31
  * so the newest line always sits against the composer - the same promise
23
- * `scrollback/canvas.ts` makes about the live tail.
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.)
24
35
  *
25
36
  * BOUNDED: at most `SCROLLBACK_LIMITS.liveTailMaxEntries` component instances per frame
26
37
  * (I-10). That is what keeps the per-frame cost the same order as the full-screen live
@@ -32,8 +43,12 @@ import type { TermCapabilities } from '../capabilities.js';
32
43
  import type { DensityMode } from '../density.js';
33
44
  import type { Theme } from '../theme.js';
34
45
  export interface LiveTailProps {
35
- /** `entries.slice(settledCount).filter(isExcluded === false)`: the same input as `buildLivePreview`. */
36
- unsettled: readonly Entry[];
46
+ /**
47
+ * The whole entry list (settled and unfinished alike); the window is taken here.
48
+ * Callers must not pre-slice it: `prev` has to come from the same list the transcript
49
+ * draws, or the top card of the band loses the separator row it has upstairs.
50
+ */
51
+ entries: readonly Entry[];
37
52
  /** Render only the last N entries; the box clips the older ones from the top. */
38
53
  maxEntries: number;
39
54
  /** Fixed height: the root box's `height`. The content never decides it (I-4). */
@@ -50,15 +65,15 @@ export interface LiveTailProps {
50
65
  }
51
66
  export interface TailEntry {
52
67
  entry: Entry;
53
- /** The entry before it in the FULL list: `EntryView` uses it to decide the separator. */
68
+ /** The entry before it in the passed list: `EntryView` decides the separator from it. */
54
69
  prev: Entry | undefined;
55
70
  }
56
71
  /**
57
- * The last `maxEntries` entries, each with its predecessor.
72
+ * The last `maxEntries` printable entries, each with its predecessor.
58
73
  *
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.
74
+ * `prev` must come from the same COMPLETE list rather than from the truncated slice, or
75
+ * the first rendered entry would look like the head of the conversation and drop the
76
+ * separation row the transcript shows above the same card.
62
77
  */
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;
78
+ 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;
@@ -2,26 +2,31 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box } from 'ink';
3
3
  import { EntryView, nowSecFor } from '../Transcript.js';
4
4
  import { SCROLLBACK_LIMITS } from './limits.js';
5
+ import { isExcluded } from './settled-boundary.js';
5
6
  /**
6
- * The last `maxEntries` entries, each with its predecessor.
7
+ * The last `maxEntries` printable entries, each with its predecessor.
7
8
  *
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.
9
+ * `prev` must come from the same COMPLETE list rather than from the truncated slice, or
10
+ * the first rendered entry would look like the head of the conversation and drop the
11
+ * separation row the transcript shows above the same card.
11
12
  */
12
- export function selectTailEntries(unsettled, maxEntries) {
13
+ export function selectTailEntries(entries, maxEntries) {
14
+ const printable = entries.filter((entry) => !isExcluded(entry));
13
15
  const limit = Number.isFinite(maxEntries) ? Math.max(0, Math.floor(maxEntries)) : 0;
14
16
  if (limit === 0)
15
17
  return [];
16
- const start = Math.max(0, unsettled.length - limit);
18
+ const start = Math.max(0, printable.length - limit);
17
19
  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 });
20
+ for (let index = start; index < printable.length; index += 1) {
21
+ selected.push({
22
+ entry: printable[index],
23
+ prev: index > 0 ? printable[index - 1] : undefined,
24
+ });
20
25
  }
21
26
  return selected;
22
27
  }
23
- export function LiveTail({ unsettled, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, }) {
28
+ export function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, }) {
24
29
  const limited = Math.min(maxEntries, SCROLLBACK_LIMITS.liveTailMaxEntries);
25
- const selected = selectTailEntries(unsettled, limited);
30
+ const selected = selectTailEntries(entries, limited);
26
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))) }));
27
32
  }