@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +2305 -2242
  2. package/README.md +119 -18
  3. package/dist/agent/controller.d.ts +29 -0
  4. package/dist/agent/controller.js +42 -1
  5. package/dist/agent/kit-apply.d.ts +56 -0
  6. package/dist/agent/kit-apply.js +209 -0
  7. package/dist/agent/model-profile-settings.d.ts +28 -0
  8. package/dist/agent/model-profile-settings.js +142 -29
  9. package/dist/agent/reducer.d.ts +1 -1
  10. package/dist/commands/builtins.js +4 -0
  11. package/dist/commands/kit-command.d.ts +20 -0
  12. package/dist/commands/kit-command.js +131 -0
  13. package/dist/commands/perf.d.ts +10 -0
  14. package/dist/commands/perf.js +1 -1
  15. package/dist/config/kit-resolution.d.ts +93 -0
  16. package/dist/config/kit-resolution.js +226 -0
  17. package/dist/config/model-kits.d.ts +224 -0
  18. package/dist/config/model-kits.js +302 -0
  19. package/dist/config/model-profile-store.d.ts +25 -0
  20. package/dist/config/model-profiles.d.ts +41 -3
  21. package/dist/config/model-profiles.js +46 -5
  22. package/dist/config/schema.d.ts +16 -0
  23. package/dist/input/keymap.js +18 -4
  24. package/dist/ui/App.js +135 -15
  25. package/dist/ui/Header.d.ts +9 -1
  26. package/dist/ui/Header.js +8 -2
  27. package/dist/ui/PromptInput.js +1 -1
  28. package/dist/ui/ScrollbackShell.d.ts +38 -13
  29. package/dist/ui/ScrollbackShell.js +4 -3
  30. package/dist/ui/StatusBar.d.ts +13 -0
  31. package/dist/ui/StatusBar.js +10 -3
  32. package/dist/ui/TeamPanel.d.ts +12 -0
  33. package/dist/ui/TeamPanel.js +10 -31
  34. package/dist/ui/Transcript.js +22 -2
  35. package/dist/ui/entries/TeamCard.d.ts +18 -2
  36. package/dist/ui/entries/TeamCard.js +47 -13
  37. package/dist/ui/layout/header-hints.d.ts +11 -1
  38. package/dist/ui/layout/header-hints.js +35 -3
  39. package/dist/ui/layout/kit-chip.d.ts +50 -0
  40. package/dist/ui/layout/kit-chip.js +81 -0
  41. package/dist/ui/layout/virtual-window.js +9 -0
  42. package/dist/ui/overlays/HelpOverlay.js +7 -0
  43. package/dist/ui/overlays/KitEditor.d.ts +23 -0
  44. package/dist/ui/overlays/KitEditor.js +101 -0
  45. package/dist/ui/overlays/KitManager.d.ts +23 -0
  46. package/dist/ui/overlays/KitManager.js +104 -0
  47. package/dist/ui/overlays/KitPicker.d.ts +49 -0
  48. package/dist/ui/overlays/KitPicker.js +58 -0
  49. package/dist/ui/overlays/ModelProfilePicker.js +16 -6
  50. package/dist/ui/overlays/SettingsScreen.js +139 -16
  51. package/dist/ui/overlays/kit-picker-state.d.ts +87 -0
  52. package/dist/ui/overlays/kit-picker-state.js +136 -0
  53. package/dist/ui/overlays/kit-settings-state.d.ts +129 -0
  54. package/dist/ui/overlays/kit-settings-state.js +267 -0
  55. package/dist/ui/overlays/model-profile-state.d.ts +26 -3
  56. package/dist/ui/overlays/model-profile-state.js +59 -6
  57. package/dist/ui/scrollback/LiveTail.d.ts +44 -10
  58. package/dist/ui/scrollback/LiveTail.js +20 -4
  59. package/dist/ui/scrollback/limits.d.ts +16 -0
  60. package/dist/ui/scrollback/limits.js +16 -0
  61. package/dist/ui/scrollback/live-preview.js +10 -3
  62. package/dist/ui/scrollback/overflow-print.d.ts +108 -0
  63. package/dist/ui/scrollback/overflow-print.js +120 -0
  64. package/dist/ui/scrollback/renderer.d.ts +11 -1
  65. package/dist/ui/scrollback/renderer.js +53 -25
  66. package/dist/ui/scrollback/tail-anchor.d.ts +68 -0
  67. package/dist/ui/scrollback/tail-anchor.js +77 -0
  68. package/dist/ui/scrollback/tail-window.d.ts +21 -8
  69. package/dist/ui/scrollback/tail-window.js +26 -10
  70. package/dist/ui/scrollback/use-scrollback-view.d.ts +38 -10
  71. package/dist/ui/scrollback/use-scrollback-view.js +372 -85
  72. package/dist/ui/team-status.d.ts +141 -0
  73. package/dist/ui/team-status.js +224 -0
  74. package/dist/ui/transcript-text.js +13 -4
  75. package/dist/ui/use-wheel-routing.js +2 -2
  76. 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, patch: { ...state.draft.patch, team } } };
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, profiles: { ...library, [`${action.role}Id`]: action.id },
162
- activateRoles: [...new Set([...state.draft.activateRoles, action.role])] } };
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
- returnPage: state.page === 'picker' ? 'picker' : 'manager',
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 is four grey rows saying `Team: 2/4 running`. That
8
- * is the "simple look" the feature was opened for. A text-only region can never fix
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
- * `justifyContent="flex-end"` PLUS `overflow="hidden"` IS CORRECTNESS, NOT STYLE: when
30
- * the content is taller than the budget the rows that get clipped are the OLDEST ones,
31
- * so the newest line always sits against the composer - the same promise
32
- * `scrollback/canvas.ts` makes about the live tail. (`ScrollbackShell` flips this to
33
- * `flex-start` for the empty-session opener, which is the one frame where the content
34
- * must hug the HEADER instead; it never renders this component.)
29
+ * TOP-ANCHORED WHILE THE CONTENT FITS, BOTTOM-ANCHORED WHEN IT DOES NOT
30
+ * (tui-first-reply-bottom-aligned, plan A-prime). A constant `flex-end` is what a user
31
+ * reports as "the first reply shows up at the bottom": the box is always the whole pool,
32
+ * so a four-row first turn was pushed against the composer and every unused row sat ABOVE
33
+ * the conversation. `tailAnchor` therefore sums the estimated height of exactly the
34
+ * entries this frame renders and asks for `flex-start` while they fit - the conversation
35
+ * is then written downwards from under the header, the way a terminal writes.
36
+ *
37
+ * THE OVERFLOW FRAME STAYS `flex-end`, AND THAT IS THE OTHER HALF OF THE FIX.
38
+ * `flex-start` PLUS `overflow="hidden"` clips the NEWEST rows (Ink's clip rectangle is
39
+ * the box's own `[top, top + height]`), and a streaming entry can never be printed into
40
+ * the scrollback to make up for it (`settled-boundary.ts` holds it unstable, and printed
41
+ * rows are permanent). `flex-end` clips the OLDEST rows instead, which is the promise
42
+ * `scrollback/canvas.ts` makes about the live tail: the newest line always sits against
43
+ * the composer. Those clipped rows are what `overflow-print.ts` prints, which is why the
44
+ * branch has to be `bottom` before anything is printed - and why `measuredRows` exists.
45
+ *
46
+ * THE SWITCH COSTS ONE DOWNWARD MOVE ON A STREAMING FRAME, AND IT IS NOT FREE. The two
47
+ * branches are only pixel-identical where the content EXACTLY fills the box, and the
48
+ * estimate cannot know that: `estimateEntryRows` charges every entry a separation row while
49
+ * `density.ts::separationRows` only draws one above a `user` entry, so the frame that
50
+ * flips is still a few rows short of the box and the block drops by those rows once -
51
+ * three on a 30-row terminal (the margin plus that over-count) - before it starts
52
+ * scrolling up a row per line. Accepted and pinned by
53
+ * `scrollback-first-reply-anchor.test.tsx`: anchoring to the top until the content
54
+ * really fills the box is exactly the frame that clips the newest row, and measuring the
55
+ * box every frame is the cost plan A-prime exists to avoid.
56
+ *
57
+ * A SETTLED WINDOW IS MEASURED ANYWAY, so the move is exact there (`measuredRows`).
58
+ * `use-scrollback-view.ts` has to measure the window to decide what the box hides, and
59
+ * `resolveTailAnchor` lets that measurement decide the anchor: `flex-end` exactly when the
60
+ * content is taller than the box. So the glitch above survives only on frames where no
61
+ * measurement exists yet - every streaming frame, i.e. exactly the case the pin covers.
35
62
  *
36
63
  * BOUNDED: at most `SCROLLBACK_LIMITS.liveTailMaxEntries` component instances per frame
37
64
  * (I-10). That is what keeps the per-frame cost the same order as the full-screen live
@@ -62,6 +89,13 @@ export interface LiveTailProps {
62
89
  nowSec?: number;
63
90
  theme: Theme;
64
91
  caps: TermCapabilities;
92
+ /**
93
+ * Measured height of exactly the entries this frame renders, when the owner of the
94
+ * measurement (`use-scrollback-view.ts`) has it. It decides the anchor INSTEAD of the
95
+ * estimate - see `resolveTailAnchor`. `undefined` on every frame without a full
96
+ * measurement, which is every streaming frame: the estimate then decides, as before.
97
+ */
98
+ measuredRows?: number;
65
99
  }
66
100
  export interface TailEntry {
67
101
  entry: Entry;
@@ -76,4 +110,4 @@ export interface TailEntry {
76
110
  * separation row the transcript shows above the same card.
77
111
  */
78
112
  export declare function selectTailEntries(entries: readonly Entry[], maxEntries: number): readonly TailEntry[];
79
- export declare function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, }: LiveTailProps): React.ReactElement;
113
+ export declare function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, measuredRows, }: LiveTailProps): React.ReactElement;
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box } from 'ink';
3
3
  import { EntryView, nowSecFor } from '../Transcript.js';
4
+ import { estimateEntryRows } from '../layout/virtual-window.js';
4
5
  import { SCROLLBACK_LIMITS } from './limits.js';
5
- import { isExcluded } from './settled-boundary.js';
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
- const printable = entries.filter((entry) => !isExcluded(entry));
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
- 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))) }));
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
- const running = entry.runs.filter((run) => !['queued', 'done', 'failed', 'aborted'].includes(run.phase)).length;
60
- const out = [fit(`Team: ${running}/${entry.runs.length} running`, cols)];
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
- return teamRows(entry, cols);
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; `[]` when it renders as nothing. */
26
+ /** Rows for one entry at `cols` columns, capped at `maxRowsPerEntry`; `[]` when it renders as nothing. */
27
27
  renderEntry(entry: Entry, prev: Entry | undefined, cols: number, expanded?: boolean): string[];
28
+ /**
29
+ * The SAME rows, UNCAPPED.
30
+ *
31
+ * "Top give-way" (`overflow-print.ts`) slices one entry across two print passes, so it
32
+ * needs the real row count and the real rows: `capRows` truncates before returning, and a
33
+ * row budget computed from a truncated array puts the cut in the wrong place (a 800-row
34
+ * answer would compute its cut on 400 rows and print rows the screen is still showing).
35
+ * The cap is applied by the caller, once, where it knows how many rows are already printed.
36
+ */
37
+ renderEntryRows(entry: Entry, prev: Entry | undefined, cols: number, expanded?: boolean): string[];
28
38
  /** Rows for an arbitrary node (the startup header and opener). */
29
39
  renderNode(node: React.ReactNode, cols: number): string[];
30
40
  /** Options for LATER renders; already printed rows never change. */