@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
@@ -17,6 +17,7 @@ import { Box, render } from 'ink';
17
17
  import { EntryView } from '../Transcript.js';
18
18
  import { getLogger } from '../../logging/logger.js';
19
19
  import { SCROLLBACK_LIMITS } from './limits.js';
20
+ import { elisionRow } from './overflow-print.js';
20
21
  /**
21
22
  * Catches a throwing entry INSIDE the offscreen tree.
22
23
  *
@@ -76,30 +77,52 @@ function trimTrailingBlank(rows) {
76
77
  }
77
78
  export function createEntryRenderer(initial) {
78
79
  let options = initial;
79
- let offscreen = null;
80
+ /**
81
+ * Resident offscreen instances, keyed by width.
82
+ *
83
+ * TWO SLOTS, NOT ONE. Ink reads `columns` once, at creation, so a width change needs a new
84
+ * instance - but a width change happens WITHIN one promotion pass as soon as the plan
85
+ * measures at the box width (`previewCols`) and prints at `contentCols`, and a single slot
86
+ * would then dispose and rebuild a complete Ink root on every such frame. Two slots cover
87
+ * both widths of the current geometry; a third width (an actual resize) evicts the oldest.
88
+ */
89
+ const slots = new Map();
90
+ const MAX_SLOTS = 2;
80
91
  let elidedRows = 0;
81
92
  let failures = 0;
82
93
  let guardKey = 0;
83
- const dispose = () => {
84
- if (!offscreen)
94
+ const disposeSlot = (cols) => {
95
+ const target = slots.get(cols);
96
+ if (!target)
85
97
  return;
86
- const current = offscreen;
87
- offscreen = null;
98
+ slots.delete(cols);
88
99
  try {
89
- current.instance.unmount();
90
- current.instance.cleanup();
100
+ target.instance.unmount();
101
+ target.instance.cleanup();
91
102
  }
92
103
  catch {
93
104
  // Tearing down a fake stream must never throw into an exit path.
94
105
  }
95
106
  };
107
+ const dispose = () => {
108
+ for (const cols of [...slots.keys()])
109
+ disposeSlot(cols);
110
+ };
96
111
  /** Ink reads `columns` once, at creation, so a width change rebuilds the instance. */
97
112
  const ensure = (cols) => {
98
- if (offscreen && offscreen.cols === cols)
99
- return offscreen;
100
- dispose();
101
- offscreen = createOffscreen(cols);
102
- return offscreen;
113
+ const resident = slots.get(cols);
114
+ if (resident) {
115
+ // Refresh the recency order so the OTHER width is the eviction candidate.
116
+ slots.delete(cols);
117
+ slots.set(cols, resident);
118
+ return resident;
119
+ }
120
+ const evictable = [...slots.keys()][0];
121
+ if (evictable !== undefined && slots.size >= MAX_SLOTS)
122
+ disposeSlot(evictable);
123
+ const created = createOffscreen(cols);
124
+ slots.set(cols, created);
125
+ return created;
103
126
  };
104
127
  const capRows = (rows) => {
105
128
  const cap = SCROLLBACK_LIMITS.maxRowsPerEntry;
@@ -107,9 +130,9 @@ export function createEntryRenderer(initial) {
107
130
  return rows;
108
131
  const hidden = rows.length - (cap - 1);
109
132
  elidedRows += hidden;
110
- return [...rows.slice(0, cap - 1), `[+${hidden} rows not printed - scrollback entry cap]`];
133
+ return [...rows.slice(0, cap - 1), elisionRow(hidden)];
111
134
  };
112
- const renderNode = (node, cols) => {
135
+ const renderFrame = (node, cols) => {
113
136
  const target = ensure(Math.max(1, Math.floor(cols)));
114
137
  target.last.value = null;
115
138
  target.failure.error = NO_FAILURE;
@@ -122,27 +145,32 @@ export function createEntryRenderer(initial) {
122
145
  throw failure;
123
146
  if (frame === null)
124
147
  throw new Error('offscreen Ink instance produced no frame');
125
- return capRows(trimTrailingBlank(frame.split('\n')));
148
+ return trimTrailingBlank(frame.split('\n'));
126
149
  };
127
150
  const failureRow = (label) => {
128
151
  failures += 1;
129
152
  return [`[aragon] ${label} could not be rendered`];
130
153
  };
154
+ const entryFrame = (entry, prev, cols, expanded) => renderFrame(_jsx(EntryView, { entry: entry, prev: prev, expanded: expanded, thinkingVisible: options.thinkingVisible, reducedMotion: options.reducedMotion, density: options.density, theme: options.theme, caps: options.caps }), cols);
155
+ const renderEntryRows = (entry, prev, cols, expanded = false) => {
156
+ try {
157
+ return entryFrame(entry, prev, cols, expanded);
158
+ }
159
+ catch (error) {
160
+ getLogger().warn('cli', 'entry_render_failed', {
161
+ id: entry.id, kind: entry.kind, error: String(error),
162
+ });
163
+ return failureRow(`entry ${entry.id} (${entry.kind})`);
164
+ }
165
+ };
131
166
  return {
132
167
  renderEntry(entry, prev, cols, expanded = false) {
133
- try {
134
- return renderNode(_jsx(EntryView, { entry: entry, prev: prev, expanded: expanded, thinkingVisible: options.thinkingVisible, reducedMotion: options.reducedMotion, density: options.density, theme: options.theme, caps: options.caps }), cols);
135
- }
136
- catch (error) {
137
- getLogger().warn('cli', 'entry_render_failed', {
138
- id: entry.id, kind: entry.kind, error: String(error),
139
- });
140
- return failureRow(`entry ${entry.id} (${entry.kind})`);
141
- }
168
+ return capRows(renderEntryRows(entry, prev, cols, expanded));
142
169
  },
170
+ renderEntryRows,
143
171
  renderNode(node, cols) {
144
172
  try {
145
- return renderNode(node, cols);
173
+ return capRows(renderFrame(node, cols));
146
174
  }
147
175
  catch (error) {
148
176
  getLogger().warn('cli', 'node_render_failed', { error: String(error) });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Where the live tail sits inside the band's preview box
3
+ * (tui-first-reply-bottom-aligned, plan A-prime).
4
+ *
5
+ * THE DEFECT THIS ANSWERS. The box is always the whole pool (`band.previewRows`, 24 rows
6
+ * on a 30-row terminal) while a first turn is four rows tall. A constant
7
+ * `justifyContent="flex-end"` therefore pushed those four rows against the composer and
8
+ * left every unused row ABOVE the conversation - the "the first reply starts at the
9
+ * bottom of the screen" report. The answer is conditional anchoring: top while the
10
+ * content fits, so the conversation is written downwards from under the header.
11
+ *
12
+ * WHY THE FALLBACK IS `bottom` AND NOT THE OTHER WAY ROUND. `flex-start` inside an
13
+ * `overflow="hidden"` box clips the NEWEST rows (Ink's clip rectangle is the box's
14
+ * `[top, top + height]`), and a streaming entry is never printable
15
+ * (`settled-boundary.ts` holds it unstable, and "printed is forever"). Overflowing to
16
+ * `flex-start` would hide the text the user is waiting for; overflowing to `flex-end`
17
+ * hides the oldest rows, which the tail window prints on its way out.
18
+ *
19
+ * THE THRESHOLD CARRIES A MARGIN. `rowOf` is an ESTIMATE, and the one available
20
+ * (`layout/virtual-window.ts::estimateEntryRows`) under-counts a `#` heading and a code
21
+ * block. The margin in `SCROLLBACK_LIMITS.tailAnchorSlackRows` absorbs that error on the
22
+ * `bottom` side, the only direction that cannot clip something new.
23
+ *
24
+ * PURE: no Ink, no I/O, no clock, and deliberately no import of `virtual-window.ts` (its
25
+ * import graph reaches a `.tsx` gutter). The row height is injected, so this stays three
26
+ * parameters and a unit test can drive it with any table it likes.
27
+ */
28
+ import type { Entry } from '../../agent/reducer.js';
29
+ /** `top` = content grows down from the header; `bottom` = newest row hugs the composer. */
30
+ export type TailAnchor = 'top' | 'bottom';
31
+ /**
32
+ * Which alignment the live tail should use for the entries it is about to render.
33
+ *
34
+ * @param entries The entries THIS FRAME renders (already windowed by `selectTailEntries`;
35
+ * summing the whole conversation would make every session past the entry cap overflow and
36
+ * anchor to the bottom forever).
37
+ * @param budgetRows Height of the box that will clip them, in rows.
38
+ * @param rowOf Estimated height of one entry, including its separation row.
39
+ * @returns `top` when the estimates plus `tailAnchorSlackRows` fit in `budgetRows`,
40
+ * `bottom` otherwise (and for the first entry that does not fit, so the loop stops early).
41
+ */
42
+ export declare function tailAnchor(entries: readonly Entry[], budgetRows: number, rowOf: (entry: Entry) => number): TailAnchor;
43
+ /**
44
+ * The anchor this frame really uses: MEASURED when the measurement exists, estimated
45
+ * otherwise.
46
+ *
47
+ * WHY A MEASURED ANSWER WINS (tui-final-reply-truncated-unscrollable). The estimate is an
48
+ * upper bound that charges a separation row this renderer may not draw, so it can claim the
49
+ * content overflows while it actually fits (the block sits at the bottom of a half-empty
50
+ * box - the very complaint `tui-first-reply-bottom-aligned` answered) and it under-counts a
51
+ * heading or a wrapped code block, so it can claim the content fits while the box is really
52
+ * clipping. The second error is the expensive one now: "top give-way" only prints while the
53
+ * box is bottom-anchored (that is the branch that clips the TOP), so a wrongly top-anchored
54
+ * box would clip the newest rows and print the oldest ones - the wrong half, twice.
55
+ *
56
+ * A measured total is available only when every window entry is stable AND already measured
57
+ * at the current geometry (`use-scrollback-view.ts` keeps that cache), which is exactly the
58
+ * frame on which a print decision is being made. Every other frame - streaming, resize,
59
+ * first sight of a new entry - falls back to the estimate, unchanged.
60
+ *
61
+ * @param measuredRows Measured height of the same entries, or `undefined` when unknown.
62
+ */
63
+ export declare function resolveTailAnchor(input: {
64
+ entries: readonly Entry[];
65
+ budgetRows: number;
66
+ rowOf: (entry: Entry) => number;
67
+ measuredRows: number | undefined;
68
+ }): TailAnchor;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Where the live tail sits inside the band's preview box
3
+ * (tui-first-reply-bottom-aligned, plan A-prime).
4
+ *
5
+ * THE DEFECT THIS ANSWERS. The box is always the whole pool (`band.previewRows`, 24 rows
6
+ * on a 30-row terminal) while a first turn is four rows tall. A constant
7
+ * `justifyContent="flex-end"` therefore pushed those four rows against the composer and
8
+ * left every unused row ABOVE the conversation - the "the first reply starts at the
9
+ * bottom of the screen" report. The answer is conditional anchoring: top while the
10
+ * content fits, so the conversation is written downwards from under the header.
11
+ *
12
+ * WHY THE FALLBACK IS `bottom` AND NOT THE OTHER WAY ROUND. `flex-start` inside an
13
+ * `overflow="hidden"` box clips the NEWEST rows (Ink's clip rectangle is the box's
14
+ * `[top, top + height]`), and a streaming entry is never printable
15
+ * (`settled-boundary.ts` holds it unstable, and "printed is forever"). Overflowing to
16
+ * `flex-start` would hide the text the user is waiting for; overflowing to `flex-end`
17
+ * hides the oldest rows, which the tail window prints on its way out.
18
+ *
19
+ * THE THRESHOLD CARRIES A MARGIN. `rowOf` is an ESTIMATE, and the one available
20
+ * (`layout/virtual-window.ts::estimateEntryRows`) under-counts a `#` heading and a code
21
+ * block. The margin in `SCROLLBACK_LIMITS.tailAnchorSlackRows` absorbs that error on the
22
+ * `bottom` side, the only direction that cannot clip something new.
23
+ *
24
+ * PURE: no Ink, no I/O, no clock, and deliberately no import of `virtual-window.ts` (its
25
+ * import graph reaches a `.tsx` gutter). The row height is injected, so this stays three
26
+ * parameters and a unit test can drive it with any table it likes.
27
+ */
28
+ import { SCROLLBACK_LIMITS } from './limits.js';
29
+ /**
30
+ * Which alignment the live tail should use for the entries it is about to render.
31
+ *
32
+ * @param entries The entries THIS FRAME renders (already windowed by `selectTailEntries`;
33
+ * summing the whole conversation would make every session past the entry cap overflow and
34
+ * anchor to the bottom forever).
35
+ * @param budgetRows Height of the box that will clip them, in rows.
36
+ * @param rowOf Estimated height of one entry, including its separation row.
37
+ * @returns `top` when the estimates plus `tailAnchorSlackRows` fit in `budgetRows`,
38
+ * `bottom` otherwise (and for the first entry that does not fit, so the loop stops early).
39
+ */
40
+ export function tailAnchor(entries, budgetRows, rowOf) {
41
+ const budget = Number.isFinite(budgetRows) ? Math.max(0, Math.floor(budgetRows)) : 0;
42
+ let rows = 0;
43
+ for (const entry of entries) {
44
+ rows += Math.max(0, rowOf(entry));
45
+ if (rows + SCROLLBACK_LIMITS.tailAnchorSlackRows > budget)
46
+ return 'bottom';
47
+ }
48
+ return 'top';
49
+ }
50
+ /**
51
+ * The anchor this frame really uses: MEASURED when the measurement exists, estimated
52
+ * otherwise.
53
+ *
54
+ * WHY A MEASURED ANSWER WINS (tui-final-reply-truncated-unscrollable). The estimate is an
55
+ * upper bound that charges a separation row this renderer may not draw, so it can claim the
56
+ * content overflows while it actually fits (the block sits at the bottom of a half-empty
57
+ * box - the very complaint `tui-first-reply-bottom-aligned` answered) and it under-counts a
58
+ * heading or a wrapped code block, so it can claim the content fits while the box is really
59
+ * clipping. The second error is the expensive one now: "top give-way" only prints while the
60
+ * box is bottom-anchored (that is the branch that clips the TOP), so a wrongly top-anchored
61
+ * box would clip the newest rows and print the oldest ones - the wrong half, twice.
62
+ *
63
+ * A measured total is available only when every window entry is stable AND already measured
64
+ * at the current geometry (`use-scrollback-view.ts` keeps that cache), which is exactly the
65
+ * frame on which a print decision is being made. Every other frame - streaming, resize,
66
+ * first sight of a new entry - falls back to the estimate, unchanged.
67
+ *
68
+ * @param measuredRows Measured height of the same entries, or `undefined` when unknown.
69
+ */
70
+ export function resolveTailAnchor(input) {
71
+ const { entries, budgetRows, rowOf, measuredRows } = input;
72
+ if (measuredRows === undefined || !Number.isFinite(measuredRows)) {
73
+ return tailAnchor(entries, budgetRows, rowOf);
74
+ }
75
+ const budget = Number.isFinite(budgetRows) ? Math.max(0, Math.floor(budgetRows)) : 0;
76
+ return measuredRows > budget ? 'bottom' : 'top';
77
+ }
@@ -10,22 +10,35 @@
10
10
  * only the unfinished cards on it. So the region now shows the last `K` PRINTABLE
11
11
  * entries - settled or not - and an entry is printed when it LEAVES that window.
12
12
  *
13
- * The window is defined by ENTRY COUNT, never by measured height: a single entry taller
14
- * than the region is clipped from the top by `justifyContent="flex-end"` and stays in
15
- * the window until `K` further entries arrive (README, known limitation 2). Measuring
16
- * heights here would need the offscreen renderer on every frame, which is the cost this
17
- * module exists to avoid.
13
+ * The window is defined by ENTRY COUNT, never by measured height; what a single entry
14
+ * taller than the region used to do is the whole subject of
15
+ * `overflow-print.ts` (tui-final-reply-truncated-unscrollable). The box still clips it
16
+ * from the top with `justifyContent="flex-end"`, but the rows it hides are now PRINTED
17
+ * into the terminal's scrollback, where the wheel already reaches, instead of waiting for
18
+ * `K` further entries (README, known limitation 2 - rewritten by that fix).
18
19
  *
19
20
  * PURE: no Ink, no I/O, no clock. `promote()` (the writer's only feeder) takes
20
- * `printBoundary`'s result and keeps its own `printedIds` authority, so "printed is
21
+ * `printBoundary`'s result and keeps its own `printed` authority, so "printed is
21
22
  * forward-only" (RV-7) is unchanged.
22
23
  *
23
24
  * PRINTABILITY != VISIBILITY. `isExcluded` entries (a non-terminal `service` card) never
24
25
  * print and never take a slot in the window; an entry that is in the window may still be
25
- * scrolled out of sight by a taller neighbour (README, known limitation 5). The second
26
- * gap is disclosed by `/perf`'s `window=<start>/<n>`, not by a second measurement.
26
+ * scrolled out of sight by a taller neighbour. That second gap is what the row-level plan
27
+ * closes, and `/perf`'s `window=<start>/<n> head=<rows>` is how it stays observable.
27
28
  */
28
29
  import type { Entry } from '../../agent/reducer.js';
30
+ /**
31
+ * Entries that take NO slot in the window: they neither print nor take a row.
32
+ *
33
+ * A LIVE DISPATCH CARD JOINS THE NON-TERMINAL SERVICE CARD HERE
34
+ * (tui-subagent-double-roster A-2'): the roster draws the live dispatch and the
35
+ * transcript draws nothing, so the window must not spend one of its slots on an
36
+ * entry that is not on screen. THE PRINT PUMP AND THE BOX MUST AGREE ABOUT SLOTS:
37
+ * so both window functions below skip it, and `LiveTail.selectTailEntries` - the
38
+ * window function the box renders - imports this one instead of spelling the
39
+ * composition out a second time.
40
+ */
41
+ export declare function isSlotless(entry: Entry): boolean;
29
42
  export interface TailWindow {
30
43
  /** First index of the window inside the full entry list. */
31
44
  start: number;
@@ -10,22 +10,38 @@
10
10
  * only the unfinished cards on it. So the region now shows the last `K` PRINTABLE
11
11
  * entries - settled or not - and an entry is printed when it LEAVES that window.
12
12
  *
13
- * The window is defined by ENTRY COUNT, never by measured height: a single entry taller
14
- * than the region is clipped from the top by `justifyContent="flex-end"` and stays in
15
- * the window until `K` further entries arrive (README, known limitation 2). Measuring
16
- * heights here would need the offscreen renderer on every frame, which is the cost this
17
- * module exists to avoid.
13
+ * The window is defined by ENTRY COUNT, never by measured height; what a single entry
14
+ * taller than the region used to do is the whole subject of
15
+ * `overflow-print.ts` (tui-final-reply-truncated-unscrollable). The box still clips it
16
+ * from the top with `justifyContent="flex-end"`, but the rows it hides are now PRINTED
17
+ * into the terminal's scrollback, where the wheel already reaches, instead of waiting for
18
+ * `K` further entries (README, known limitation 2 - rewritten by that fix).
18
19
  *
19
20
  * PURE: no Ink, no I/O, no clock. `promote()` (the writer's only feeder) takes
20
- * `printBoundary`'s result and keeps its own `printedIds` authority, so "printed is
21
+ * `printBoundary`'s result and keeps its own `printed` authority, so "printed is
21
22
  * forward-only" (RV-7) is unchanged.
22
23
  *
23
24
  * PRINTABILITY != VISIBILITY. `isExcluded` entries (a non-terminal `service` card) never
24
25
  * print and never take a slot in the window; an entry that is in the window may still be
25
- * scrolled out of sight by a taller neighbour (README, known limitation 5). The second
26
- * gap is disclosed by `/perf`'s `window=<start>/<n>`, not by a second measurement.
26
+ * scrolled out of sight by a taller neighbour. That second gap is what the row-level plan
27
+ * closes, and `/perf`'s `window=<start>/<n> head=<rows>` is how it stays observable.
27
28
  */
28
29
  import { isExcluded, settledCount } from './settled-boundary.js';
30
+ import { isLiveTeamCard } from '../team-status.js';
31
+ /**
32
+ * Entries that take NO slot in the window: they neither print nor take a row.
33
+ *
34
+ * A LIVE DISPATCH CARD JOINS THE NON-TERMINAL SERVICE CARD HERE
35
+ * (tui-subagent-double-roster A-2'): the roster draws the live dispatch and the
36
+ * transcript draws nothing, so the window must not spend one of its slots on an
37
+ * entry that is not on screen. THE PRINT PUMP AND THE BOX MUST AGREE ABOUT SLOTS:
38
+ * so both window functions below skip it, and `LiveTail.selectTailEntries` - the
39
+ * window function the box renders - imports this one instead of spelling the
40
+ * composition out a second time.
41
+ */
42
+ export function isSlotless(entry) {
43
+ return isExcluded(entry) || isLiveTeamCard(entry);
44
+ }
29
45
  /**
30
46
  * The index of the first entry that is still on screen: count backwards up to
31
47
  * `maxEntries` PRINTABLE entries. Everything before it has left the region and is the
@@ -41,7 +57,7 @@ export function tailWindowStart(entries, maxEntries) {
41
57
  return entries.length;
42
58
  let seen = 0;
43
59
  for (let i = entries.length - 1; i >= 0; i -= 1) {
44
- if (isExcluded(entries[i]))
60
+ if (isSlotless(entries[i]))
45
61
  continue;
46
62
  seen += 1;
47
63
  if (seen === limit)
@@ -53,7 +69,7 @@ export function tailWindowStart(entries, maxEntries) {
53
69
  /** `entries.slice(start)` with the entries that never print dropped. */
54
70
  export function selectTailWindow(entries, maxEntries) {
55
71
  const start = tailWindowStart(entries, maxEntries);
56
- return { start, entries: entries.slice(start).filter((entry) => !isExcluded(entry)) };
72
+ return { start, entries: entries.slice(start).filter((entry) => !isSlotless(entry)) };
57
73
  }
58
74
  /**
59
75
  * The print boundary: never print what the region is still showing.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * `useScrollbackView` — the promotion pipeline of native-scroll mode
3
- * (tui-native-terminal-scroll §4.4 / §6.2, tui-full-height-live-region D3/D4/D7/D8).
3
+ * (tui-native-terminal-scroll §4.4 / §6.2, tui-full-height-live-region D3/D4/D7/D8,
4
+ * tui-final-reply-truncated-unscrollable A-prime).
4
5
  *
5
6
  * `App` calls it ALWAYS (hooks cannot be conditional); `enabled: false` makes every
6
7
  * effect a no-op and keeps `scrollMode: 'app'` byte-identical to the pre-feature
@@ -12,8 +13,15 @@
12
13
  * writer. Entries still on screen are never printed - at `H = rows - 1` a printed
13
14
  * row is pushed back off the top by the next frame, and a second copy in the
14
15
  * scrollback would be a duplicate the user meets by scrolling up (I-F8).
15
- * 2. Computes the live-region budget, the window and the tail rows for the shell.
16
- * 3. Flushes the window before either reset path (`/clear`, `--resume`) and on exit,
16
+ * 2. Gives way at the TOP (tui-final-reply-truncated-unscrollable). The window is still
17
+ * counted in ENTRIES, so a settled answer taller than the box used to sit inside the
18
+ * window forever, clipped by `justifyContent="flex-end"`, in a mode whose wheel only
19
+ * scrolls the terminal's scrollback: the clipped rows existed nowhere the user could
20
+ * reach. The rows the box hides are now measured (`overflow-print.ts`) and printed, so
21
+ * `scrollback + screen` covers every row exactly once. Screen unchanged, reachability
22
+ * fixed.
23
+ * 3. Computes the live-region budget, the window and the tail rows for the shell.
24
+ * 4. Flushes the window before either reset path (`/clear`, `--resume`) and on exit,
17
25
  * so "on screen but not printed yet" can never become "lost" (D8 / I-F12).
18
26
  *
19
27
  * THE HEADER AND THE OPENER ARE NOT PRINTED HERE ANY MORE (D3/D4). They are slots of
@@ -27,13 +35,20 @@
27
35
  * issued from inside the main tree's commit (a layout effect) is deferred until that
28
36
  * commit finishes, so its frame would not exist when the effect reads it. A microtask
29
37
  * runs after the commit has unwound, where the offscreen render is synchronous again.
30
- * Everything the pass needs is read from refs for that reason.
31
38
  *
32
- * `printedIds` is the ONLY authority on "already printed" (RV-7). The cursor is just
33
- * a hint for where to resume scanning: entries leave the list by retain-ring
34
- * trimming AND by reducer deletion (empty settled assistants, abandoned tool
35
- * cards), so an id-keyed cursor can dangle, and then the only correct answer is to
36
- * rescan and let `printedIds` skip.
39
+ * THE PASS READS THE COMMITTED FRAME, NOT THE LATEST RENDER. `latest` is refreshed during
40
+ * render, so it can be a frame ahead of the screen; `committed` is written by a layout
41
+ * effect and therefore describes exactly the frame the user is looking at. With
42
+ * entry-granular bookkeeping the difference was harmless (printing was idempotent per
43
+ * entry); with ROW-granular bookkeeping it would slice this frame's picture with another
44
+ * frame's row numbers, and the result is a duplicate or a hole.
45
+ *
46
+ * `printed` is the ONLY authority on "already printed" (RV-7): a `Map` from entry id to
47
+ * the leading rows already handed to the writer, and the width they were measured at. The
48
+ * cursor is just a hint for where to resume scanning: entries leave the list by retain-ring
49
+ * trimming AND by reducer deletion (empty settled assistants, abandoned tool cards), so an
50
+ * id-keyed cursor can dangle, and then the only correct answer is to rescan and let
51
+ * `printed` skip.
37
52
  */
38
53
  import type { Entry } from '../../agent/reducer.js';
39
54
  import type { TermCapabilities } from '../capabilities.js';
@@ -50,7 +65,7 @@ export interface ScrollbackBridge {
50
65
  requestFlush: () => void;
51
66
  /**
52
67
  * Installed by the hook on mount (reads the latest entries through a ref).
53
- * `restore()` calls it on every exit path; it is idempotent through `printedIds`
68
+ * `restore()` calls it on every exit path; it is idempotent through `printed`
54
69
  * and it releases the offscreen renderer when done.
55
70
  */
56
71
  settleAllNow: () => void;
@@ -89,6 +104,11 @@ export interface UseScrollbackViewArgs {
89
104
  panelEnabled: boolean;
90
105
  /** `buildTeamPanelLayout().rowCount`; 0 when nothing is dispatched. */
91
106
  teamRowsNatural: number;
107
+ /**
108
+ * `state.expandedToolIds` - REQUIRED, because the height of a window entry depends on it
109
+ * and a forgotten value would measure an expansion the region is not drawing.
110
+ */
111
+ expandedToolIds: Record<string, true>;
92
112
  }
93
113
  export interface ScrollbackView {
94
114
  budget: ScrollbackBudget | null;
@@ -101,6 +121,14 @@ export interface ScrollbackView {
101
121
  */
102
122
  tailEntries: readonly Entry[];
103
123
  windowStart: number;
124
+ /**
125
+ * Measured height of `tailEntries`, once every one of them is stable AND measured at the
126
+ * current geometry; `undefined` otherwise. `LiveTail` prefers it over the estimate - see
127
+ * `tail-anchor.ts::resolveTailAnchor` for why the measured answer has to win.
128
+ */
129
+ measuredTailRows: number | undefined;
130
+ /** Rows the top give-way has printed this session; `/perf`'s `head=`. */
131
+ headRows: number;
104
132
  /** Print a fresh EXPANDED copy of an entry; false when it cannot be found / rendered. */
105
133
  reprintExpanded(entryId: string): boolean;
106
134
  /** Promote everything still unprinted (shutdown) - `flushUnprinted(latest entries)`. */