@aragon-agent/cli 0.6.33 → 0.6.35
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 +119 -18
- package/dist/agent/controller.d.ts +29 -0
- package/dist/agent/controller.js +42 -1
- package/dist/agent/kit-apply.d.ts +56 -0
- package/dist/agent/kit-apply.js +209 -0
- package/dist/agent/model-profile-settings.d.ts +28 -0
- package/dist/agent/model-profile-settings.js +142 -29
- package/dist/agent/reducer.d.ts +1 -1
- package/dist/commands/builtins.js +4 -0
- package/dist/commands/kit-command.d.ts +20 -0
- package/dist/commands/kit-command.js +131 -0
- package/dist/commands/perf.d.ts +10 -0
- package/dist/commands/perf.js +1 -1
- package/dist/config/kit-resolution.d.ts +93 -0
- package/dist/config/kit-resolution.js +226 -0
- package/dist/config/model-kits.d.ts +224 -0
- package/dist/config/model-kits.js +302 -0
- package/dist/config/model-profile-store.d.ts +25 -0
- package/dist/config/model-profiles.d.ts +41 -3
- package/dist/config/model-profiles.js +46 -5
- package/dist/config/schema.d.ts +16 -0
- package/dist/input/keymap.js +18 -4
- package/dist/ui/App.js +135 -15
- package/dist/ui/Header.d.ts +9 -1
- package/dist/ui/Header.js +8 -2
- package/dist/ui/PromptInput.js +1 -1
- package/dist/ui/ScrollbackShell.d.ts +38 -13
- package/dist/ui/ScrollbackShell.js +4 -3
- package/dist/ui/StatusBar.d.ts +13 -0
- package/dist/ui/StatusBar.js +10 -3
- package/dist/ui/TeamPanel.d.ts +12 -0
- package/dist/ui/TeamPanel.js +10 -31
- package/dist/ui/Transcript.js +22 -2
- package/dist/ui/entries/TeamCard.d.ts +18 -2
- package/dist/ui/entries/TeamCard.js +47 -13
- package/dist/ui/layout/header-hints.d.ts +11 -1
- package/dist/ui/layout/header-hints.js +35 -3
- package/dist/ui/layout/kit-chip.d.ts +50 -0
- package/dist/ui/layout/kit-chip.js +81 -0
- package/dist/ui/layout/virtual-window.js +9 -0
- package/dist/ui/overlays/HelpOverlay.js +7 -0
- package/dist/ui/overlays/KitEditor.d.ts +23 -0
- package/dist/ui/overlays/KitEditor.js +101 -0
- package/dist/ui/overlays/KitManager.d.ts +23 -0
- package/dist/ui/overlays/KitManager.js +104 -0
- package/dist/ui/overlays/KitPicker.d.ts +49 -0
- package/dist/ui/overlays/KitPicker.js +58 -0
- package/dist/ui/overlays/ModelProfilePicker.js +16 -6
- package/dist/ui/overlays/SettingsScreen.js +139 -16
- package/dist/ui/overlays/kit-picker-state.d.ts +87 -0
- package/dist/ui/overlays/kit-picker-state.js +136 -0
- package/dist/ui/overlays/kit-settings-state.d.ts +129 -0
- package/dist/ui/overlays/kit-settings-state.js +267 -0
- package/dist/ui/overlays/model-profile-state.d.ts +26 -3
- package/dist/ui/overlays/model-profile-state.js +59 -6
- package/dist/ui/scrollback/LiveTail.d.ts +44 -10
- package/dist/ui/scrollback/LiveTail.js +20 -4
- package/dist/ui/scrollback/limits.d.ts +16 -0
- package/dist/ui/scrollback/limits.js +16 -0
- package/dist/ui/scrollback/live-preview.js +10 -3
- 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 +21 -8
- package/dist/ui/scrollback/tail-window.js +26 -10
- package/dist/ui/scrollback/use-scrollback-view.d.ts +38 -10
- package/dist/ui/scrollback/use-scrollback-view.js +372 -85
- package/dist/ui/team-status.d.ts +141 -0
- package/dist/ui/team-status.js +224 -0
- package/dist/ui/transcript-text.js +13 -4
- package/dist/ui/use-wheel-routing.js +2 -2
- package/package.json +2 -2
|
@@ -41,10 +41,38 @@ export function createModelProfileState(draft) {
|
|
|
41
41
|
return { draft: copy, page: 'root', role: 'main', returnPage: 'manager',
|
|
42
42
|
bindNew: false, dirty: false };
|
|
43
43
|
}
|
|
44
|
+
/** The kits whose three slots name `id`, for the delete confirmation (D-9). */
|
|
45
|
+
export function kitsUsingProfile(profiles, id) {
|
|
46
|
+
return (profiles?.kits ?? []).filter((kit) => kit.mainProfileId === id
|
|
47
|
+
|| kit.fastProfileId === id
|
|
48
|
+
|| (kit.subagent.model === 'profile' && kit.subagent.modelProfileId === id));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Drop `slot` from the session kit (§4.3 row 2 / AC-38).
|
|
52
|
+
*
|
|
53
|
+
* THE DRAFT IS NARROWED HERE, NOT ONLY AT SAVE TIME. `releasedSlots` already
|
|
54
|
+
* strips the same slot from the transaction before it commits, but the ROOT PAGE
|
|
55
|
+
* paints the kit's effective value for every HELD slot - so without this line a
|
|
56
|
+
* user picks a profile and the row keeps showing the one they just replaced,
|
|
57
|
+
* which is the most confusing thing a settings screen can do. Releasing the last
|
|
58
|
+
* slot ends the kit: `held: []` is not a state any surface can render.
|
|
59
|
+
*/
|
|
60
|
+
function releaseHeldSlot(draft, slot) {
|
|
61
|
+
const current = draft.sessionKit;
|
|
62
|
+
if (!current || !current.held.includes(slot))
|
|
63
|
+
return draft;
|
|
64
|
+
const held = current.held.filter((item) => item !== slot);
|
|
65
|
+
return { ...draft, sessionKit: held.length ? { id: current.id, held } : null };
|
|
66
|
+
}
|
|
44
67
|
/** Describe references using text so ASCII and no-color modes remain usable. */
|
|
45
68
|
export function profileUsers(profiles, id, subagentProfileId) {
|
|
69
|
+
// THE KITS ARE REFERENCES TOO (D-9 / RV-P2-6): "Main + kit "Deep work"" is what
|
|
70
|
+
// makes the confirmation honest, because deleting this entry also deletes that
|
|
71
|
+
// kit. The empty case must stay empty - a leading ` + ` is what a naive
|
|
72
|
+
// `join` after an unconditional push produces.
|
|
46
73
|
return [profiles?.mainId === id ? 'Main' : '', profiles?.fastId === id ? 'Fast' : '',
|
|
47
|
-
subagentProfileId === id ? 'Subagent' : ''
|
|
74
|
+
subagentProfileId === id ? 'Subagent' : '',
|
|
75
|
+
...kitsUsingProfile(profiles, id).map((kit) => `kit "${kit.name}"`)]
|
|
48
76
|
.filter(Boolean).join(' + ');
|
|
49
77
|
}
|
|
50
78
|
/**
|
|
@@ -155,14 +183,27 @@ export function modelProfileReducer(state, action) {
|
|
|
155
183
|
? { model: 'fast', modelProfileId: null }
|
|
156
184
|
: { model: 'profile', modelProfileId: action.id };
|
|
157
185
|
return { ...state, page: 'root', dirty: true, error: undefined,
|
|
158
|
-
draft: { ...state.draft,
|
|
186
|
+
draft: releaseHeldSlot({ ...state.draft,
|
|
187
|
+
patch: { ...state.draft.patch, team } }, 'subagent') };
|
|
159
188
|
}
|
|
189
|
+
// `'binding'` NEVER REACHES THIS REDUCER: a kit slot's choice is applied
|
|
190
|
+
// by `kitReducer` (`kit-main` / `kit-fast`), because the value belongs to
|
|
191
|
+
// an editor draft rather than to a `${role}Id` library key. Returning the
|
|
192
|
+
// state unchanged is the honest answer to an impossible action.
|
|
193
|
+
if (action.role === 'binding')
|
|
194
|
+
return state;
|
|
160
195
|
return { ...state, page: 'root', dirty: true, error: undefined,
|
|
161
|
-
draft: { ...state.draft,
|
|
162
|
-
|
|
196
|
+
draft: releaseHeldSlot({ ...state.draft,
|
|
197
|
+
profiles: { ...library, [`${action.role}Id`]: action.id },
|
|
198
|
+
activateRoles: [...new Set([...state.draft.activateRoles, action.role])] }, action.role) };
|
|
163
199
|
}
|
|
164
200
|
case 'edit': return { ...state, editor: createProfileEditor(action.profile), page: 'editor',
|
|
165
|
-
|
|
201
|
+
// A PROFILE BORN IN A KIT SLOT RETURNS TO THAT SLOT (§4.5): the picker's
|
|
202
|
+
// `Create profile` action is the only way to invent one and it is offered on
|
|
203
|
+
// the kit's slot chooser - landing in the profile MANAGER there would
|
|
204
|
+
// abandon the half-built kit behind a page the user never asked for.
|
|
205
|
+
returnPage: state.page === 'picker' || state.page === 'kit-slot'
|
|
206
|
+
? state.page : 'manager',
|
|
166
207
|
bindNew: action.bindNew ?? false, error: undefined };
|
|
167
208
|
case 'editor-field': return editField(state, action);
|
|
168
209
|
case 'retain-key': return state.editor?.original.apiKey ? { ...state,
|
|
@@ -206,17 +247,29 @@ export function modelProfileReducer(state, action) {
|
|
|
206
247
|
// when it named THIS entry. `removeModelProfile`'s throw stays as the
|
|
207
248
|
// bottom-line defense - the UI has already unbound, so it cannot fire
|
|
208
249
|
// on this path.
|
|
250
|
+
//
|
|
251
|
+
// AND THE KITS GO WITH IT (agent-model-kits D-9 / I-8): a kit naming a
|
|
252
|
+
// deleted profile can neither be applied nor repaired from any screen, so
|
|
253
|
+
// leaving it behind would be leaving a permanent error. The confirmation
|
|
254
|
+
// page has already named them, so this is the action the user agreed to.
|
|
255
|
+
const doomed = kitsUsingProfile(library, id);
|
|
256
|
+
const defaultKitKept = doomed.some((kit) => kit.id === (library.defaultKitId ?? null))
|
|
257
|
+
? null : library.defaultKitId;
|
|
258
|
+
const sessionReleased = doomed.some((kit) => kit.id === state.draft.sessionKit?.id)
|
|
259
|
+
? null : state.draft.sessionKit;
|
|
209
260
|
const profiles = {
|
|
210
261
|
...library,
|
|
211
262
|
mainId: library.mainId === id ? null : library.mainId,
|
|
212
263
|
fastId: library.fastId === id ? null : library.fastId,
|
|
213
264
|
entries: library.entries.filter((entry) => entry.id !== id),
|
|
265
|
+
kits: (library.kits ?? []).filter((kit) => !doomed.includes(kit)),
|
|
266
|
+
defaultKitId: defaultKitKept,
|
|
214
267
|
};
|
|
215
268
|
const teamReset = draftSubagentProfileId(state.draft) === id
|
|
216
269
|
? { team: { model: 'main', modelProfileId: null } }
|
|
217
270
|
: {};
|
|
218
271
|
return { ...state, page: 'manager', dirty: true,
|
|
219
|
-
draft: { ...state.draft, profiles,
|
|
272
|
+
draft: { ...state.draft, profiles, sessionKit: sessionReleased,
|
|
220
273
|
patch: { ...state.draft.patch, ...teamReset } },
|
|
221
274
|
deleteId: undefined, deleteUsers: undefined,
|
|
222
275
|
// No orphan editor for a deleted entry (3.7).
|
|
@@ -4,8 +4,8 @@
|
|
|
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
|
|
7
|
-
* running dispatch that projection
|
|
8
|
-
*
|
|
7
|
+
* running dispatch that projection was four grey rows saying `Team: 2/4 running`. It
|
|
8
|
+
* was the "simple look" the feature was opened for. A text-only region can never fix
|
|
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
|
*
|
|
@@ -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
|
-
import {
|
|
6
|
+
import { isSlotless } from './tail-window.js';
|
|
7
|
+
import { resolveTailAnchor } from './tail-anchor.js';
|
|
6
8
|
/**
|
|
7
9
|
* The last `maxEntries` printable entries, each with its predecessor.
|
|
8
10
|
*
|
|
@@ -11,7 +13,12 @@ import { isExcluded } from './settled-boundary.js';
|
|
|
11
13
|
* separation row the transcript shows above the same card.
|
|
12
14
|
*/
|
|
13
15
|
export function selectTailEntries(entries, maxEntries) {
|
|
14
|
-
|
|
16
|
+
// A LIVE DISPATCH CARD TAKES NO SLOT (tui-subagent-double-roster A-2): the roster
|
|
17
|
+
// draws it and `EntryView` returns null for it, so the window must not spend one of
|
|
18
|
+
// its slots on an entry that is not on screen. `isSlotless` is `tail-window.ts`'s
|
|
19
|
+
// predicate - the one `selectTailWindow` and the print pump apply - so the box and
|
|
20
|
+
// `planOverflowPrint` cannot drift into different windows.
|
|
21
|
+
const printable = entries.filter((entry) => !isSlotless(entry));
|
|
15
22
|
const limit = Number.isFinite(maxEntries) ? Math.max(0, Math.floor(maxEntries)) : 0;
|
|
16
23
|
if (limit === 0)
|
|
17
24
|
return [];
|
|
@@ -25,8 +32,17 @@ export function selectTailEntries(entries, maxEntries) {
|
|
|
25
32
|
}
|
|
26
33
|
return selected;
|
|
27
34
|
}
|
|
28
|
-
export function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, }) {
|
|
35
|
+
export function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, measuredRows, }) {
|
|
29
36
|
const limited = Math.min(maxEntries, SCROLLBACK_LIMITS.liveTailMaxEntries);
|
|
30
37
|
const selected = selectTailEntries(entries, limited);
|
|
31
|
-
|
|
38
|
+
// `estimateEntryRows` is the estimate the transcript's virtual window already uses, so
|
|
39
|
+
// the anchor can never disagree with the height the rest of the interface plans for.
|
|
40
|
+
const estimateRows = (entry) => estimateEntryRows(entry, cols, density, expandedToolIds[entry.id] === true, thinkingVisible);
|
|
41
|
+
const anchor = resolveTailAnchor({
|
|
42
|
+
entries: selected.map((tailEntry) => tailEntry.entry),
|
|
43
|
+
budgetRows: rows,
|
|
44
|
+
rowOf: estimateRows,
|
|
45
|
+
measuredRows,
|
|
46
|
+
});
|
|
47
|
+
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
48
|
}
|
|
@@ -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
|
*
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { clampLiveText } from '../live-clamp.js';
|
|
21
21
|
import { wrapToRows } from '../layout/wrap-rows.js';
|
|
22
22
|
import { isExcluded } from './settled-boundary.js';
|
|
23
|
+
import { isLiveTeamCard, outcomePhrase, rosterCounts } from '../team-status.js';
|
|
23
24
|
import { clipLine } from './writer-shapes.js';
|
|
24
25
|
const MAX_TEAM_RUN_ROWS = 3;
|
|
25
26
|
const TOOL_LIVE_ROWS = 6;
|
|
@@ -56,8 +57,10 @@ function toolRows(entry, cols) {
|
|
|
56
57
|
return out;
|
|
57
58
|
}
|
|
58
59
|
function teamRows(entry, cols) {
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
// ONLY A SETTLED CARD REACHES HERE (the live one projects nothing, below), so the
|
|
61
|
+
// outcome phrase - `team-status.ts`'s own sentence - replaces an always-zero fraction.
|
|
62
|
+
const counts = rosterCounts(entry.runs);
|
|
63
|
+
const out = [fit(`Team: ${outcomePhrase(counts)}`, cols)];
|
|
61
64
|
for (const run of entry.runs.slice(0, MAX_TEAM_RUN_ROWS)) {
|
|
62
65
|
out.push(fit(` ${run.label} ${run.phase}`, cols));
|
|
63
66
|
}
|
|
@@ -71,7 +74,11 @@ function entryRows(entry, input) {
|
|
|
71
74
|
case 'tool':
|
|
72
75
|
return toolRows(entry, cols);
|
|
73
76
|
case 'team':
|
|
74
|
-
|
|
77
|
+
// THE LIVE DISPATCH HAS ONE SURFACE (tui-subagent-double-roster A-2): this
|
|
78
|
+
// projection is the text fallback for the same slot `LiveTail` fills, so a
|
|
79
|
+
// `Team: n/m running` line here would be the second live block again, one level
|
|
80
|
+
// down. A settled card still projects normally.
|
|
81
|
+
return isLiveTeamCard(entry) ? [] : teamRows(entry, cols);
|
|
75
82
|
case 'todo':
|
|
76
83
|
return [fit(`Todo: ${entry.doneCount}/${entry.total}`, cols)];
|
|
77
84
|
case 'compaction':
|
|
@@ -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. */
|