@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.
- package/CHANGELOG.md +2305 -2242
- package/README.md +3007 -2970
- package/dist/commands/perf.d.ts +10 -0
- package/dist/commands/perf.js +1 -1
- package/dist/ui/App.js +27 -3
- package/dist/ui/ScrollbackShell.d.ts +20 -10
- package/dist/ui/ScrollbackShell.js +4 -3
- package/dist/ui/scrollback/LiveTail.d.ts +42 -8
- package/dist/ui/scrollback/LiveTail.js +13 -2
- package/dist/ui/scrollback/limits.d.ts +16 -0
- package/dist/ui/scrollback/limits.js +16 -0
- package/dist/ui/scrollback/overflow-print.d.ts +108 -0
- package/dist/ui/scrollback/overflow-print.js +120 -0
- package/dist/ui/scrollback/renderer.d.ts +11 -1
- package/dist/ui/scrollback/renderer.js +53 -25
- package/dist/ui/scrollback/tail-anchor.d.ts +68 -0
- package/dist/ui/scrollback/tail-anchor.js +77 -0
- package/dist/ui/scrollback/tail-window.d.ts +9 -8
- package/dist/ui/scrollback/tail-window.js +9 -8
- package/dist/ui/scrollback/use-scrollback-view.d.ts +38 -10
- package/dist/ui/scrollback/use-scrollback-view.js +366 -86
- package/package.json +2 -2
package/dist/commands/perf.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/perf.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
* -
|
|
37
|
-
*
|
|
38
|
-
* rows in the upper half of the screen - the original
|
|
39
|
-
* region, where the `gap` assertion could not see it
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
64
|
-
*
|
|
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:
|
|
10
|
-
//
|
|
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:
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
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
|
|
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. */
|