@aragon-agent/cli 0.6.31 → 0.6.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +2970 -2826
  3. package/dist/agent/controller.d.ts +46 -0
  4. package/dist/agent/controller.js +175 -7
  5. package/dist/agent/job-notices.d.ts +143 -0
  6. package/dist/agent/job-notices.js +182 -0
  7. package/dist/agent/reducer.d.ts +21 -1
  8. package/dist/agent/reducer.js +7 -0
  9. package/dist/agent/system-prompt.d.ts +12 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +236 -8
  12. package/dist/commands/builtins.js +23 -8
  13. package/dist/commands/perf.d.ts +77 -1
  14. package/dist/commands/perf.js +37 -5
  15. package/dist/config/cli-commands.d.ts +27 -0
  16. package/dist/config/cli-commands.js +54 -1
  17. package/dist/config/env.js +38 -0
  18. package/dist/config/load.js +50 -1
  19. package/dist/config/schema.d.ts +73 -0
  20. package/dist/config/schema.js +34 -0
  21. package/dist/config/store.js +14 -1
  22. package/dist/exec/events.d.ts +29 -3
  23. package/dist/exec/events.js +3 -12
  24. package/dist/exec/runner.d.ts +24 -0
  25. package/dist/exec/runner.js +79 -0
  26. package/dist/input/cursor-report.d.ts +61 -0
  27. package/dist/input/cursor-report.js +97 -0
  28. package/dist/input/stdin-filter.d.ts +22 -0
  29. package/dist/input/stdin-filter.js +48 -3
  30. package/dist/proc/limits.d.ts +76 -2
  31. package/dist/proc/limits.js +83 -2
  32. package/dist/proc/log-mirror.d.ts +59 -0
  33. package/dist/proc/log-mirror.js +203 -0
  34. package/dist/proc/log-ring.d.ts +22 -0
  35. package/dist/proc/log-ring.js +20 -1
  36. package/dist/proc/notices.d.ts +57 -0
  37. package/dist/proc/notices.js +101 -0
  38. package/dist/proc/progress.d.ts +40 -0
  39. package/dist/proc/progress.js +135 -0
  40. package/dist/proc/prompt.d.ts +24 -2
  41. package/dist/proc/prompt.js +51 -2
  42. package/dist/proc/supervisor.d.ts +35 -2
  43. package/dist/proc/supervisor.js +142 -43
  44. package/dist/proc/types.d.ts +89 -1
  45. package/dist/proc/wait.d.ts +57 -0
  46. package/dist/proc/wait.js +133 -0
  47. package/dist/session/persist.js +3 -1
  48. package/dist/session/validate-session.js +17 -0
  49. package/dist/tools/bash-tool.d.ts +12 -1
  50. package/dist/tools/bash-tool.js +100 -13
  51. package/dist/tools/index.d.ts +26 -2
  52. package/dist/tools/index.js +43 -2
  53. package/dist/tools/job-tools.d.ts +54 -0
  54. package/dist/tools/job-tools.js +329 -0
  55. package/dist/tools/proc-tools.d.ts +1 -1
  56. package/dist/tools/proc-tools.js +49 -17
  57. package/dist/ui/ActivityLine.js +7 -0
  58. package/dist/ui/App.js +98 -12
  59. package/dist/ui/ScrollbackShell.d.ts +62 -11
  60. package/dist/ui/ScrollbackShell.js +11 -3
  61. package/dist/ui/StatusBar.d.ts +5 -0
  62. package/dist/ui/StatusBar.js +1 -0
  63. package/dist/ui/Transcript.d.ts +14 -0
  64. package/dist/ui/Transcript.js +14 -2
  65. package/dist/ui/entries/ServiceCard.d.ts +17 -1
  66. package/dist/ui/entries/ServiceCard.js +39 -4
  67. package/dist/ui/layout/status-layout.d.ts +13 -1
  68. package/dist/ui/layout/status-layout.js +10 -0
  69. package/dist/ui/layout/virtual-window.js +14 -1
  70. package/dist/ui/run-status-row.d.ts +12 -0
  71. package/dist/ui/screen.d.ts +17 -4
  72. package/dist/ui/screen.js +14 -6
  73. package/dist/ui/scrollback/LiveTail.d.ts +79 -0
  74. package/dist/ui/scrollback/LiveTail.js +32 -0
  75. package/dist/ui/scrollback/band.d.ts +114 -0
  76. package/dist/ui/scrollback/band.js +130 -0
  77. package/dist/ui/scrollback/budget.d.ts +51 -10
  78. package/dist/ui/scrollback/budget.js +69 -20
  79. package/dist/ui/scrollback/canvas.d.ts +99 -0
  80. package/dist/ui/scrollback/canvas.js +113 -0
  81. package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
  82. package/dist/ui/scrollback/cursor-probe.js +133 -0
  83. package/dist/ui/scrollback/limits.d.ts +61 -4
  84. package/dist/ui/scrollback/limits.js +61 -4
  85. package/dist/ui/scrollback/live-preview.d.ts +18 -10
  86. package/dist/ui/scrollback/live-preview.js +14 -11
  87. package/dist/ui/scrollback/scrollback-writer.d.ts +104 -16
  88. package/dist/ui/scrollback/scrollback-writer.js +368 -82
  89. package/dist/ui/scrollback/tail-window.d.ts +60 -0
  90. package/dist/ui/scrollback/tail-window.js +73 -0
  91. package/dist/ui/scrollback/use-scrollback-view.d.ts +34 -15
  92. package/dist/ui/scrollback/use-scrollback-view.js +110 -59
  93. package/dist/ui/scrollback/writer-shapes.d.ts +68 -2
  94. package/dist/ui/scrollback/writer-shapes.js +53 -8
  95. package/package.json +3 -3
@@ -74,6 +74,15 @@ export declare function enterAltScreen(stdout: NodeJS.WriteStream | undefined, o
74
74
  export interface NativeScreenOptions {
75
75
  /** Same discipline as `AltScreenOptions.bracketedPaste`: a filter that consumes it exists. */
76
76
  readonly bracketedPaste?: boolean;
77
+ /**
78
+ * Line feeds to write BEFORE anything else, so the first batch starts from the
79
+ * bottom row of the screen (tui-bottom-anchored-live-region D4 rung 2).
80
+ *
81
+ * `cli.tsx` passes `rows - 1` when the cursor probe could not measure the
82
+ * position, and `0` when it did. Filling on a measured terminal would undo the
83
+ * measurement — and cost the page-flip this feature exists to avoid.
84
+ */
85
+ readonly anchorFillRows?: number;
77
86
  }
78
87
  /**
79
88
  * Native-scroll mode's counterpart of `enterAltScreen` (tui-native-terminal-scroll
@@ -81,10 +90,14 @@ export interface NativeScreenOptions {
81
90
  * the conversation lands in the terminal's own scrollback; there is no alternate
82
91
  * screen, no mouse reporting and no DEC 1007 save/restore.
83
92
  *
84
- * The only mode it touches is DEC 2004 (bracketed paste), which changes how input
85
- * is encoded and has nothing to do with scrolling. Returns the SAME `ScreenHandle`
86
- * shape, so the four idempotent restore paths in `cli.tsx` need not know which mode
87
- * is running. A non-TTY stdout is a hard no-op, like `enterAltScreen`.
93
+ * The only modes it touches are DEC 2004 (bracketed paste), which changes how input
94
+ * is encoded and has nothing to do with scrolling, and — when the bottom anchor could
95
+ * not be measured — the one-off fill that puts the cursor on the last row. Returns the
96
+ * SAME `ScreenHandle` shape, so the four idempotent restore paths in `cli.tsx` need not
97
+ * know which mode is running. A non-TTY stdout is a hard no-op, like `enterAltScreen`.
98
+ *
99
+ * ORDER IS LOAD-BEARING: the fill goes FIRST. Written after `?2004h` it would be the
100
+ * same bytes, but written after any content it would scroll that content away.
88
101
  */
89
102
  export declare function enterNativeScreen(stdout: NodeJS.WriteStream | undefined, options?: NativeScreenOptions): ScreenHandle;
90
103
  /**
package/dist/ui/screen.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * Hoisting it into `buildProgram()` / `parseAsync` would push `\x1b[?1049h`
12
12
  * into `aragon -p "…" > out.txt`, `aragon config set`, and `aragon --version`.
13
13
  */
14
+ import { fillBytes } from './scrollback/writer-shapes.js';
14
15
  const ENTER_ALT = '\x1b[?1049h';
15
16
  const LEAVE_ALT = '\x1b[?1049l';
16
17
  const CURSOR_HOME = '\x1b[H';
@@ -187,17 +188,24 @@ export function enterAltScreen(stdout, options = { mouse: false }) {
187
188
  * the conversation lands in the terminal's own scrollback; there is no alternate
188
189
  * screen, no mouse reporting and no DEC 1007 save/restore.
189
190
  *
190
- * The only mode it touches is DEC 2004 (bracketed paste), which changes how input
191
- * is encoded and has nothing to do with scrolling. Returns the SAME `ScreenHandle`
192
- * shape, so the four idempotent restore paths in `cli.tsx` need not know which mode
193
- * is running. A non-TTY stdout is a hard no-op, like `enterAltScreen`.
191
+ * The only modes it touches are DEC 2004 (bracketed paste), which changes how input
192
+ * is encoded and has nothing to do with scrolling, and — when the bottom anchor could
193
+ * not be measured — the one-off fill that puts the cursor on the last row. Returns the
194
+ * SAME `ScreenHandle` shape, so the four idempotent restore paths in `cli.tsx` need not
195
+ * know which mode is running. A non-TTY stdout is a hard no-op, like `enterAltScreen`.
196
+ *
197
+ * ORDER IS LOAD-BEARING: the fill goes FIRST. Written after `?2004h` it would be the
198
+ * same bytes, but written after any content it would scroll that content away.
194
199
  */
195
200
  export function enterNativeScreen(stdout, options = {}) {
196
201
  if (!stdout || !stdout.isTTY || typeof stdout.write !== 'function')
197
202
  return NOOP_HANDLE;
198
203
  const bracketedPaste = options.bracketedPaste === true;
199
- if (bracketedPaste)
200
- safeWrite(stdout, ENABLE_BRACKETED_PASTE);
204
+ const fillRows = Math.max(0, Math.floor(options.anchorFillRows ?? 0));
205
+ const head = (fillRows > 0 ? fillBytes(fillRows) : '') +
206
+ (bracketedPaste ? ENABLE_BRACKETED_PASTE : '');
207
+ if (head.length > 0)
208
+ safeWrite(stdout, head);
201
209
  let restored = false;
202
210
  return {
203
211
  restore() {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * LiveTail - the band's TAIL WINDOW, rendered with the real entry components
3
+ * (release-gate-and-native-panels §6.5, tui-full-height-live-region D6).
4
+ *
5
+ * WHY THIS FILE EXISTS. Native scroll mode cannot print an entry until it settles, so
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
9
+ * it, which is why the region's content type changed from `readonly string[]` to a
10
+ * fixed-height box that can hold components.
11
+ *
12
+ * IT SHOWS THE CONVERSATION, NOT JUST THE UNFINISHED PART (D6/D7). The input used to be
13
+ * `entries.slice(settledCount)` - the entries the reducer had not finished writing -
14
+ * because everything settled was printed into the scrollback immediately. Now the region
15
+ * is the whole screen, `H = rows - 1`, so a printed row is pushed off the top by the next
16
+ * frame; the region therefore holds the last `SCROLLBACK_LIMITS.liveTailMaxEntries`
17
+ * PRINTABLE entries, settled or not, and an entry is printed when it LEAVES that window.
18
+ * The window is `tail-window.ts`'s definition; this component only slides it over the
19
+ * list and lets the fixed-height box clip from the top.
20
+ *
21
+ * IT RENDERS `EntryView` AND NOTHING ELSE. The mapping from an entry to its component
22
+ * (`assistant` / `tool` / `team` / `todo` / service cards, notice frames, the `null`
23
+ * cases) lives in `Transcript.tsx` exactly once, together with `EntryFrame`'s gutter,
24
+ * the separation rows, the per-entry `nowSecFor` clock and the memo comparator. A
25
+ * second enumeration here would be a copy that drifts: the band's cards would lose the
26
+ * left rail glyph and the transcript's would keep it, and the two halves of one screen
27
+ * would disagree about the same entry.
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.)
35
+ *
36
+ * BOUNDED: at most `SCROLLBACK_LIMITS.liveTailMaxEntries` component instances per frame
37
+ * (I-10). That is what keeps the per-frame cost the same order as the full-screen live
38
+ * tail; growing it makes the cost grow linearly because this box has no windowing.
39
+ */
40
+ import React from 'react';
41
+ import type { Entry } from '../../agent/reducer.js';
42
+ import type { TermCapabilities } from '../capabilities.js';
43
+ import type { DensityMode } from '../density.js';
44
+ import type { Theme } from '../theme.js';
45
+ export interface LiveTailProps {
46
+ /**
47
+ * The whole entry list (settled and unfinished alike); the window is taken here.
48
+ * Callers must not pre-slice it: `prev` has to come from the same list the transcript
49
+ * draws, or the top card of the band loses the separator row it has upstairs.
50
+ */
51
+ entries: readonly Entry[];
52
+ /** Render only the last N entries; the box clips the older ones from the top. */
53
+ maxEntries: number;
54
+ /** Fixed height: the root box's `height`. The content never decides it (I-4). */
55
+ rows: number;
56
+ /** `budget.previewCols` - the width left after the rail took its column (I-5). */
57
+ cols: number;
58
+ expandedToolIds: Record<string, true>;
59
+ thinkingVisible: boolean;
60
+ reducedMotion: boolean;
61
+ density: DensityMode;
62
+ nowSec?: number;
63
+ theme: Theme;
64
+ caps: TermCapabilities;
65
+ }
66
+ export interface TailEntry {
67
+ entry: Entry;
68
+ /** The entry before it in the passed list: `EntryView` decides the separator from it. */
69
+ prev: Entry | undefined;
70
+ }
71
+ /**
72
+ * The last `maxEntries` printable entries, each with its predecessor.
73
+ *
74
+ * `prev` must come from the same COMPLETE list rather than from the truncated slice, or
75
+ * the first rendered entry would look like the head of the conversation and drop the
76
+ * separation row the transcript shows above the same card.
77
+ */
78
+ export declare function selectTailEntries(entries: readonly Entry[], maxEntries: number): readonly TailEntry[];
79
+ export declare function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, }: LiveTailProps): React.ReactElement;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box } from 'ink';
3
+ import { EntryView, nowSecFor } from '../Transcript.js';
4
+ import { SCROLLBACK_LIMITS } from './limits.js';
5
+ import { isExcluded } from './settled-boundary.js';
6
+ /**
7
+ * The last `maxEntries` printable entries, each with its predecessor.
8
+ *
9
+ * `prev` must come from the same COMPLETE list rather than from the truncated slice, or
10
+ * the first rendered entry would look like the head of the conversation and drop the
11
+ * separation row the transcript shows above the same card.
12
+ */
13
+ export function selectTailEntries(entries, maxEntries) {
14
+ const printable = entries.filter((entry) => !isExcluded(entry));
15
+ const limit = Number.isFinite(maxEntries) ? Math.max(0, Math.floor(maxEntries)) : 0;
16
+ if (limit === 0)
17
+ return [];
18
+ const start = Math.max(0, printable.length - limit);
19
+ const selected = [];
20
+ for (let index = start; index < printable.length; index += 1) {
21
+ selected.push({
22
+ entry: printable[index],
23
+ prev: index > 0 ? printable[index - 1] : undefined,
24
+ });
25
+ }
26
+ return selected;
27
+ }
28
+ export function LiveTail({ entries, maxEntries, rows, cols, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, }) {
29
+ const limited = Math.min(maxEntries, SCROLLBACK_LIMITS.liveTailMaxEntries);
30
+ 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))) }));
32
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Height and column budget of the native-scroll PANEL BAND
3
+ * (release-gate-and-native-panels §6.2, tui-full-height-live-region D5).
4
+ *
5
+ * Native scroll mode drops the fixed frame, so it used to drop the panels with it:
6
+ * the TODO rail lived in `AppShell`'s middle band and the subagent roster in the
7
+ * viewport footer, and neither is rendered once `ScrollbackShell` takes over. The
8
+ * band is the replacement - one row pool shared by the roster (`TeamPanel`), the tail
9
+ * and the rail (`TodoPanel`), sitting directly above the composer.
10
+ *
11
+ * `fill` IS THE FEATURE (D5). In `fill` mode the band takes the WHOLE pool:
12
+ * `bandRows = remaining`, `railRows = bandRows` (so the rail spans every row the user
13
+ * can see) and `previewRows = bandRows - teamRows` (so the tail column ends against
14
+ * the composer). Before `fill`, the band asked for "just enough" rows and every row it
15
+ * did not ask for became the canvas `gap` - the blank band between the result and the
16
+ * running card that this feature removes.
17
+ *
18
+ * PURE: no Ink, no clock, no terminal, no config read. Every invariant below is
19
+ * therefore established BY CONSTRUCTION for all input combinations and can be
20
+ * enumerated by a property test (AC-9..AC-14, AC-34, AC-F3/F4):
21
+ *
22
+ * - `bandRows <= remaining` and therefore `liveRows <= rows - 1` (I-4). Ink writes
23
+ * `clearTerminal` - which contains `ESC[3J`, erasing the user's scrollback - as
24
+ * soon as a frame is as tall as the terminal.
25
+ * - `previewCols + railCols === contentCols`, always (AC-10). The rail is a column
26
+ * INSIDE the region, so the preview loses exactly the columns the rail takes and
27
+ * nothing else (I-5: printing keeps the full `contentCols`).
28
+ * - `teamRows > 0` leaves room for the rail plus one preview row (AC-12).
29
+ * - `fill` mode: `bandRows === remaining`, `railRows === bandRows` when the rail is
30
+ * visible, and `teamRows + previewRows === bandRows`.
31
+ *
32
+ * THERE IS EXACTLY ONE CALL SITE: `buildScrollbackBudget` (I-8). This module is
33
+ * exported so it can be tested, not so a second caller can allocate rows of its own
34
+ * - `remaining` is computed there, and a second copy of it is a second authority.
35
+ */
36
+ export type BandReason =
37
+ /** Both panels are on screen. */
38
+ 'visible'
39
+ /** No active plan: no rail. Same name and meaning as `TodoRailLayout`'s `'empty'`. */
40
+ | 'empty'
41
+ /** `cfg.todo.panel === false`. */
42
+ | 'disabled'
43
+ /** An overlay is open: the band yields to it. */
44
+ | 'overlay'
45
+ /** `todoRailWidth(contentCols) === 0` (terminal narrower than 76 columns). */
46
+ | 'narrow'
47
+ /** The remaining rows cannot hold `panelMinRows` for the rail. */
48
+ | 'short';
49
+ export interface LiveBandInput {
50
+ /**
51
+ * `buildFrameBudget().viewportRows`: every row the band may use (the pool). Computed
52
+ * once by `buildScrollbackBudget`; callers must not re-derive it.
53
+ */
54
+ remaining: number;
55
+ /** `cols - 1`: width of the whole band. `previewCols` is derived from it. */
56
+ contentCols: number;
57
+ /** `todoRailWidth(contentCols)`; `layout/rail.ts` stays the width authority. */
58
+ railCols: number;
59
+ /** `state.todos.items.length` (0 = no active plan). */
60
+ itemCount: number;
61
+ /** `cfg.todo.panel`. */
62
+ panelEnabled: boolean;
63
+ overlayOpen: boolean;
64
+ /** `buildTeamPanelLayout().rowCount`; 0 when there is nothing to show. */
65
+ teamRowsNatural: number;
66
+ /** Hysteresis: the rail rows the previous frame already used. */
67
+ heldRailRows: number;
68
+ /** Hysteresis: the roster rows the previous frame already used. */
69
+ heldTeamRows: number;
70
+ /**
71
+ * The band takes the whole pool (native mode: always true, set by the budget).
72
+ *
73
+ * A plain boolean rather than "absence of a cap" because the difference is semantic:
74
+ * with `fill` the allocation is INDEPENDENT of the content (which is what makes the
75
+ * region the screen), while without it the band is sized to what it has to show.
76
+ */
77
+ fill: boolean;
78
+ }
79
+ export interface LiveBand {
80
+ reason: BandReason;
81
+ /** Band height: `remaining` in `fill` mode, `teamRows + previewRows` otherwise. */
82
+ bandRows: number;
83
+ teamRows: number;
84
+ previewRows: number;
85
+ /** Row budget of the rail: the whole band (the rail spans it). 0 when hidden. */
86
+ railRows: number;
87
+ /** The input `railCols` when visible, 0 when hidden (`/perf` reads `rail=0x0`). */
88
+ railCols: number;
89
+ /** `max(0, contentCols - railCols)`; always sums with `railCols` to `contentCols`. */
90
+ previewCols: number;
91
+ /** Hysteresis output, fed back on the next frame. */
92
+ heldRailRows: number;
93
+ heldTeamRows: number;
94
+ }
95
+ /**
96
+ * Allocate the band. THE ORDER OF THE STEPS IS THE SPECIFICATION - reordering them
97
+ * changes the priority between "what this run is doing" (rail + roster) and "the
98
+ * words arriving right now" (tail), which is a product decision, not a detail.
99
+ *
100
+ * 0. Normalise every numeric input (`NaN` / `Infinity` / negatives become 0).
101
+ * 1. An open overlay takes the whole pool (the band would otherwise double-count it).
102
+ * 2. Seat the roster first, but never at the cost of the rail plus one tail row:
103
+ * a dispatch is transient, the plan is the shape of the turn, both must fit.
104
+ * 3. Decide the rail's visibility, in the same reason order as `buildTodoRailLayout`.
105
+ * 4. RESERVE the rail's rows - only when the band is NOT filling. `fill` derives
106
+ * `railRows` from `bandRows` in step 7, so the reservation has no consumer there.
107
+ * 5. The tail takes what is left: the whole column in `fill` mode, `previewWanted`
108
+ * clamped by the pool otherwise.
109
+ * 6. `bandRows`: the pool in `fill` mode, `max(railReserved, teamRows + previewRows)`
110
+ * otherwise (where a rail must still be there with no tail rows).
111
+ * 7. Columns: the input `contentCols` is the only width authority.
112
+ * 8. Hand the late-arriving values (the hysteresis) back out for the next frame.
113
+ */
114
+ export declare function buildLiveBand(input: LiveBandInput): LiveBand;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Height and column budget of the native-scroll PANEL BAND
3
+ * (release-gate-and-native-panels §6.2, tui-full-height-live-region D5).
4
+ *
5
+ * Native scroll mode drops the fixed frame, so it used to drop the panels with it:
6
+ * the TODO rail lived in `AppShell`'s middle band and the subagent roster in the
7
+ * viewport footer, and neither is rendered once `ScrollbackShell` takes over. The
8
+ * band is the replacement - one row pool shared by the roster (`TeamPanel`), the tail
9
+ * and the rail (`TodoPanel`), sitting directly above the composer.
10
+ *
11
+ * `fill` IS THE FEATURE (D5). In `fill` mode the band takes the WHOLE pool:
12
+ * `bandRows = remaining`, `railRows = bandRows` (so the rail spans every row the user
13
+ * can see) and `previewRows = bandRows - teamRows` (so the tail column ends against
14
+ * the composer). Before `fill`, the band asked for "just enough" rows and every row it
15
+ * did not ask for became the canvas `gap` - the blank band between the result and the
16
+ * running card that this feature removes.
17
+ *
18
+ * PURE: no Ink, no clock, no terminal, no config read. Every invariant below is
19
+ * therefore established BY CONSTRUCTION for all input combinations and can be
20
+ * enumerated by a property test (AC-9..AC-14, AC-34, AC-F3/F4):
21
+ *
22
+ * - `bandRows <= remaining` and therefore `liveRows <= rows - 1` (I-4). Ink writes
23
+ * `clearTerminal` - which contains `ESC[3J`, erasing the user's scrollback - as
24
+ * soon as a frame is as tall as the terminal.
25
+ * - `previewCols + railCols === contentCols`, always (AC-10). The rail is a column
26
+ * INSIDE the region, so the preview loses exactly the columns the rail takes and
27
+ * nothing else (I-5: printing keeps the full `contentCols`).
28
+ * - `teamRows > 0` leaves room for the rail plus one preview row (AC-12).
29
+ * - `fill` mode: `bandRows === remaining`, `railRows === bandRows` when the rail is
30
+ * visible, and `teamRows + previewRows === bandRows`.
31
+ *
32
+ * THERE IS EXACTLY ONE CALL SITE: `buildScrollbackBudget` (I-8). This module is
33
+ * exported so it can be tested, not so a second caller can allocate rows of its own
34
+ * - `remaining` is computed there, and a second copy of it is a second authority.
35
+ */
36
+ import { TODO_LIMITS } from '../../todo/limits.js';
37
+ import { SCROLLBACK_LIMITS } from './limits.js';
38
+ /** Invalid geometry becomes zero, exactly like `layout/team-panel.ts::nonNegativeInt`. */
39
+ function nonNegativeInt(value) {
40
+ return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
41
+ }
42
+ /**
43
+ * Allocate the band. THE ORDER OF THE STEPS IS THE SPECIFICATION - reordering them
44
+ * changes the priority between "what this run is doing" (rail + roster) and "the
45
+ * words arriving right now" (tail), which is a product decision, not a detail.
46
+ *
47
+ * 0. Normalise every numeric input (`NaN` / `Infinity` / negatives become 0).
48
+ * 1. An open overlay takes the whole pool (the band would otherwise double-count it).
49
+ * 2. Seat the roster first, but never at the cost of the rail plus one tail row:
50
+ * a dispatch is transient, the plan is the shape of the turn, both must fit.
51
+ * 3. Decide the rail's visibility, in the same reason order as `buildTodoRailLayout`.
52
+ * 4. RESERVE the rail's rows - only when the band is NOT filling. `fill` derives
53
+ * `railRows` from `bandRows` in step 7, so the reservation has no consumer there.
54
+ * 5. The tail takes what is left: the whole column in `fill` mode, `previewWanted`
55
+ * clamped by the pool otherwise.
56
+ * 6. `bandRows`: the pool in `fill` mode, `max(railReserved, teamRows + previewRows)`
57
+ * otherwise (where a rail must still be there with no tail rows).
58
+ * 7. Columns: the input `contentCols` is the only width authority.
59
+ * 8. Hand the late-arriving values (the hysteresis) back out for the next frame.
60
+ */
61
+ export function buildLiveBand(input) {
62
+ const remaining = nonNegativeInt(input.remaining);
63
+ const contentCols = nonNegativeInt(input.contentCols);
64
+ const railCols = nonNegativeInt(input.railCols);
65
+ const itemCount = nonNegativeInt(input.itemCount);
66
+ const teamRowsNatural = nonNegativeInt(input.teamRowsNatural);
67
+ const heldRailRows = nonNegativeInt(input.heldRailRows);
68
+ const heldTeamRows = nonNegativeInt(input.heldTeamRows);
69
+ const fill = input.fill === true;
70
+ if (input.overlayOpen) {
71
+ return {
72
+ reason: 'overlay', bandRows: remaining, teamRows: 0, previewRows: 0,
73
+ railRows: 0, railCols: 0, previewCols: contentCols, heldRailRows: 0, heldTeamRows: 0,
74
+ };
75
+ }
76
+ const teamCeiling = Math.min(teamRowsNatural, Math.max(0, remaining - TODO_LIMITS.panelMinRows - 1));
77
+ // THE ROSTER IS SEATED AT ITS NATURAL (CLAMPED) HEIGHT, NOT AT THE REMEMBERED ONE.
78
+ // Seeding from `heldTeamRows` deadlocks the panel: a fresh dispatch starts with no
79
+ // memory, so frame one would seat zero rows, remember zero, and no later frame could
80
+ // ever seat the roster - the panel this feature exists to restore would never appear
81
+ // (found while implementing; recorded as IF-1 in the spec). The memory below only
82
+ // ever ADDS height back after a squeeze; it never withholds it.
83
+ //
84
+ // THE FLOOR SURVIVES `fill` ON PURPOSE (spec §5.3.2; RV-P2-5, recorded as not changed
85
+ // back). It holds 4 rows back from the roster even when the rail is not drawn, which
86
+ // looks like waste in `fill` mode - but it is the vetted `AC-12` behaviour, and
87
+ // `TodoPanel`'s 3-5 row compact form needs those rows to name the running item. The
88
+ // only visible cost is 4 blank rows at the TOP of the left column (the tail still
89
+ // ends against the composer), which is not one of the three reported defects; changing
90
+ // it here would reopen a different feature in the same commit.
91
+ const teamRows = teamCeiling;
92
+ const reason = !input.panelEnabled ? 'disabled'
93
+ : itemCount === 0 ? 'empty'
94
+ : railCols === 0 ? 'narrow'
95
+ : remaining - teamRows < SCROLLBACK_LIMITS.railBandMinRows ? 'short'
96
+ : 'visible';
97
+ const railVisible = reason === 'visible';
98
+ // NO CONSUMER IN `fill` MODE (step 7 derives `railRows` from `bandRows`); kept as the
99
+ // documented historical formula so the non-fill allocation stays readable and a future
100
+ // caller that does not fill cannot silently lose the rail's reservation.
101
+ const railReserved = railVisible && !fill
102
+ ? Math.min(Math.max(heldRailRows, TODO_LIMITS.panelFullRows), Math.max(0, remaining - teamRows - 1))
103
+ : 0;
104
+ const previewRows = fill
105
+ ? Math.max(0, remaining - teamRows)
106
+ // Non-fill: the tail takes what the rail's reservation leaves. The two row caps that
107
+ // used to clamp this line (`previewCap` / `previewWanted`) are RETIRED (D9) - they
108
+ // were the only inputs that made this branch differ from `fill`, so a non-fill caller
109
+ // now gets the pool too. The branch stays as the named expression of the older policy.
110
+ : Math.max(0, remaining - teamRows - railReserved);
111
+ const bandRows = fill ? remaining : Math.max(railReserved, teamRows + previewRows);
112
+ const railRows = railVisible ? bandRows : 0;
113
+ const railColsOut = railVisible ? railCols : 0;
114
+ return {
115
+ reason,
116
+ bandRows,
117
+ teamRows,
118
+ previewRows,
119
+ railRows,
120
+ railCols: railColsOut,
121
+ previewCols: Math.max(0, contentCols - railColsOut),
122
+ // MONOTONE MEMORY, NOT A SHRINKING ONE (I-6). A rail that collapsed to zero rows
123
+ // because the terminal shrank for two frames would otherwise come back four rows
124
+ // shorter and the band would visibly jump on every resize wobble. The RESET points
125
+ // (an emptied plan, a finished dispatch, a resize) live in the one place that owns
126
+ // the lifecycle, `use-scrollback-view.ts`; this function only remembers.
127
+ heldRailRows: Math.max(heldRailRows, railRows),
128
+ heldTeamRows: Math.max(heldTeamRows, teamRows),
129
+ };
130
+ }
@@ -1,24 +1,54 @@
1
1
  /**
2
- * Height budget of the native-scroll LIVE REGION (tui-native-terminal-scroll §4.6).
2
+ * Height budget of the native-scroll LIVE REGION
3
+ * (tui-native-terminal-scroll §4.6, tui-full-height-live-region D1/D3/D5).
3
4
  *
4
5
  * I-S1 · `liveRows <= max(0, rows - 1)` for every input. Ink writes
5
6
  * `clearTerminal` (which contains `ESC[3J`, erasing the user's scrollback) as soon
6
- * as a frame is as tall as the terminal; a one-row margin is the only thing between
7
- * a tidy region and data loss, so it is established by construction here and
8
- * enumerated by a property test rather than trusted.
7
+ * as a frame is as tall as the terminal. This feature takes I-S1's ONE row of margin
8
+ * and nothing else: `liveRows === rows - 1` exactly, which is why the frame-height
9
+ * guard (I-F13 / AC-F17) exists - there is no slack left to absorb a stray row.
10
+ *
11
+ * THE REGION IS THE SCREEN. The region used to be only as tall as the band asked for,
12
+ * and every row it did not ask for became `gap` - blank rows ABOVE the content, which
13
+ * is what the user reported as "the app starts halfway down the screen" and as "a big
14
+ * blank between the result and the running card". The band now takes the whole pool
15
+ * (`fill`), so the region is `[header][band][composer][status]` top to bottom and
16
+ * `gap` cannot exist: `resolveCanvasLayout` is given `frameRows === capacity` and
17
+ * derives `gap = 0` (see the spec's §5.5 proof, which is why the writer needs no
18
+ * change at all).
9
19
  *
10
20
  * The composer, popup and status rows come from the SAME `buildFrameBudget` the
11
21
  * full-screen layout uses, so the two modes cannot disagree about how tall the
12
- * editor is. What differs is the top: there is no header, so the row it would
13
- * occupy joins the pool shared by the overlay and the streaming preview.
22
+ * editor is. What differs is the top: the header is a real slot here (D3), and the
23
+ * row it occupies is spent from the region, not from the printed transcript.
24
+ *
25
+ * THIS IS THE ONLY CALL SITE OF `buildLiveBand` (I-8). `remaining` is computed here
26
+ * once and handed to the band; an `App` that computed its own copy would make
27
+ * `liveRows <= rows - 1` depend on two numbers happening to agree instead of on the
28
+ * construction that proves it.
14
29
  */
30
+ import { type LiveBand, type LiveBandInput } from './band.js';
15
31
  import { type FrameBudgetInput } from '../layout/budget.js';
32
+ /**
33
+ * Everything the band needs that this module derives for itself: `remaining`,
34
+ * `contentCols`, `railCols`, `overlayOpen` and `fill` are computed below - `fill` is
35
+ * NOT part of the caller's input (I-8: the budget decides it, and "the band fills the
36
+ * pool" is not a preference an `App` may hold). The caller supplies only the facts it
37
+ * alone knows (plan, panel switch, roster size, hysteresis), never a number this
38
+ * module is supposed to own.
39
+ */
40
+ export type ScrollbackBandInput = Omit<LiveBandInput, 'remaining' | 'contentCols' | 'railCols' | 'overlayOpen' | 'fill'>;
16
41
  export interface ScrollbackBudgetInput extends FrameBudgetInput {
17
42
  overlayOpen: boolean;
18
- /** Preview rows the content would like; the budget grants what fits. */
19
- liveTailWanted: number;
20
43
  /** The writer tripped its tall-frame guard: degrade to the placeholder until a resize. */
21
44
  forceInactive?: boolean;
45
+ /**
46
+ * REQUIRED, not optional (the same rule `TeamPanel`'s header records as D-13). An
47
+ * optional band would mean "forgot to pass it" renders silently without panels -
48
+ * exactly the defect this feature exists to remove - while a required field is a
49
+ * compile error at the one place that forgets.
50
+ */
51
+ band: ScrollbackBandInput;
22
52
  }
23
53
  export interface ScrollbackBudget {
24
54
  rows: number;
@@ -27,6 +57,12 @@ export interface ScrollbackBudget {
27
57
  contentCols: number;
28
58
  /** Terminal below the 40x12 floor: the region is a one-row placeholder. */
29
59
  inactive: boolean;
60
+ /**
61
+ * The header slot, a real row inside the region (D3). 0 on a degraded frame: the
62
+ * placeholder draws no header, and reporting 1 there would break the row identity
63
+ * (I-F4) on exactly the frames `/perf` is read to explain.
64
+ */
65
+ headerRows: number;
30
66
  statusRows: 0 | 1 | 2;
31
67
  composerRows: number;
32
68
  composerSlotRows: number;
@@ -34,10 +70,15 @@ export interface ScrollbackBudget {
34
70
  popupRows: number;
35
71
  popupMaxHeight: number;
36
72
  overlayRows: number;
73
+ /** Tail rows: the band's left column below the roster (`= band.previewRows`). */
37
74
  liveTailRows: number;
38
- /** Rows above the composer: overlay or preview. */
75
+ /** Rows above the composer: `= band.bandRows = remaining`. */
39
76
  topRows: number;
40
- /** Total live-region height; what Ink renders. */
77
+ /** Total live-region height: `rows - 1`, the whole screen above the parked row. */
41
78
  liveRows: number;
79
+ /** Height and column allocation of the panel band; the single authority is `band.ts`. */
80
+ band: LiveBand;
81
+ /** Preview/tail width: `contentCols - railCols`. Printing keeps `contentCols`. */
82
+ previewCols: number;
42
83
  }
43
84
  export declare function buildScrollbackBudget(input: ScrollbackBudgetInput): ScrollbackBudget;
@@ -1,44 +1,93 @@
1
1
  /**
2
- * Height budget of the native-scroll LIVE REGION (tui-native-terminal-scroll §4.6).
2
+ * Height budget of the native-scroll LIVE REGION
3
+ * (tui-native-terminal-scroll §4.6, tui-full-height-live-region D1/D3/D5).
3
4
  *
4
5
  * I-S1 · `liveRows <= max(0, rows - 1)` for every input. Ink writes
5
6
  * `clearTerminal` (which contains `ESC[3J`, erasing the user's scrollback) as soon
6
- * as a frame is as tall as the terminal; a one-row margin is the only thing between
7
- * a tidy region and data loss, so it is established by construction here and
8
- * enumerated by a property test rather than trusted.
7
+ * as a frame is as tall as the terminal. This feature takes I-S1's ONE row of margin
8
+ * and nothing else: `liveRows === rows - 1` exactly, which is why the frame-height
9
+ * guard (I-F13 / AC-F17) exists - there is no slack left to absorb a stray row.
10
+ *
11
+ * THE REGION IS THE SCREEN. The region used to be only as tall as the band asked for,
12
+ * and every row it did not ask for became `gap` - blank rows ABOVE the content, which
13
+ * is what the user reported as "the app starts halfway down the screen" and as "a big
14
+ * blank between the result and the running card". The band now takes the whole pool
15
+ * (`fill`), so the region is `[header][band][composer][status]` top to bottom and
16
+ * `gap` cannot exist: `resolveCanvasLayout` is given `frameRows === capacity` and
17
+ * derives `gap = 0` (see the spec's §5.5 proof, which is why the writer needs no
18
+ * change at all).
9
19
  *
10
20
  * The composer, popup and status rows come from the SAME `buildFrameBudget` the
11
21
  * full-screen layout uses, so the two modes cannot disagree about how tall the
12
- * editor is. What differs is the top: there is no header, so the row it would
13
- * occupy joins the pool shared by the overlay and the streaming preview.
22
+ * editor is. What differs is the top: the header is a real slot here (D3), and the
23
+ * row it occupies is spent from the region, not from the printed transcript.
24
+ *
25
+ * THIS IS THE ONLY CALL SITE OF `buildLiveBand` (I-8). `remaining` is computed here
26
+ * once and handed to the band; an `App` that computed its own copy would make
27
+ * `liveRows <= rows - 1` depend on two numbers happening to agree instead of on the
28
+ * construction that proves it.
14
29
  */
30
+ import { buildLiveBand } from './band.js';
15
31
  import { buildFrameBudget } from '../layout/budget.js';
16
- import { SCROLLBACK_LIMITS } from './limits.js';
32
+ import { todoRailWidth } from '../layout/rail.js';
33
+ /**
34
+ * The band of a frame that shows no content at all (below the 40x12 floor, or after
35
+ * the writer's tall-frame guard tripped). It is a REAL value rather than `undefined`
36
+ * because `/perf` and the render tests must be able to read the reason on exactly the
37
+ * frames where something went wrong (I-14 / AC-23).
38
+ */
39
+ function degradedBand(contentCols) {
40
+ return {
41
+ reason: 'short', bandRows: 0, teamRows: 0, previewRows: 0,
42
+ railRows: 0, railCols: 0, previewCols: contentCols, heldRailRows: 0, heldTeamRows: 0,
43
+ };
44
+ }
17
45
  export function buildScrollbackBudget(input) {
18
46
  const base = buildFrameBudget(input);
19
47
  const contentCols = Math.max(1, base.cols - 1);
20
48
  if (base.inactive || input.forceInactive === true) {
21
49
  return {
22
- rows: base.rows, cols: base.cols, contentCols, inactive: true, statusRows: 0,
23
- composerRows: 0, composerSlotRows: 0, composerCols: base.composerCols, popupRows: 0,
24
- popupMaxHeight: 0, overlayRows: 0, liveTailRows: 0, topRows: 0,
50
+ rows: base.rows, cols: base.cols, contentCols, inactive: true, headerRows: 0,
51
+ statusRows: 0, composerRows: 0, composerSlotRows: 0, composerCols: base.composerCols,
52
+ popupRows: 0, popupMaxHeight: 0, overlayRows: 0, liveTailRows: 0, topRows: 0,
25
53
  liveRows: Math.max(0, Math.min(1, base.rows - 1)),
54
+ band: degradedBand(contentCols), previewCols: contentCols,
26
55
  };
27
56
  }
28
- // The full-screen frame spends one row on the header; here it joins the pool.
29
- const remaining = Math.max(0, base.viewportRows + base.headerRows);
30
- const overlayRows = input.overlayOpen ? remaining : 0;
31
- const tailCap = Math.min(SCROLLBACK_LIMITS.maxLiveTailRows, Math.floor(base.rows * SCROLLBACK_LIMITS.liveTailRowShare));
32
- const liveTailRows = input.overlayOpen
33
- ? 0
34
- : Math.max(0, Math.min(Math.floor(input.liveTailWanted), remaining, tailCap));
35
- const topRows = overlayRows + liveTailRows;
36
- const liveRows = topRows + base.composerSlotRows + base.statusRows;
57
+ // D1 · H = C. `frameRows` is `rows - 1` by construction (`layout/budget.ts`), so the
58
+ // region ends exactly on the row above the writer's parked cursor.
59
+ const headerRows = 1;
60
+ const liveRows = Math.max(1, base.frameRows);
61
+ // The whole pool: everything between the header and the status line. Two independent
62
+ // derivations put its floor at 1, so `max(0, ...)` is defensive only:
63
+ // 1. `composerSlotRows = 2 + clampDraftRows(rows, draftRows)` and
64
+ // `draftMaxRows(rows) = max(1, min(6, floor(rows / 4)))`, so on the narrowest
65
+ // full-screen frame (`rows = 12`) `remaining >= 11 - 1 - 5 - 1 = 4`;
66
+ // 2. with the completion menu at its maximum, `popupMaxHeight` already subtracts
67
+ // this same pair of rows, so `remaining = popupMaxHeight + 1 - popupRows >= 1`.
68
+ const remaining = Math.max(0, liveRows - headerRows - base.composerSlotRows - base.statusRows);
69
+ const band = buildLiveBand({
70
+ ...input.band,
71
+ remaining,
72
+ contentCols,
73
+ railCols: todoRailWidth(contentCols),
74
+ // The band fills the pool: no row of the region is left blank, which is the whole
75
+ // point of the feature (D5). Taken from HERE, never from `input.band`.
76
+ fill: true,
77
+ // Taken from THIS input, never from `input.band`: an overlay that the frame knows
78
+ // about but the band did not would draw no overlay at all, and the shell keys the
79
+ // overlay's rows off the band.
80
+ overlayOpen: input.overlayOpen,
81
+ });
82
+ const overlayRows = band.reason === 'overlay' ? band.bandRows : 0;
83
+ const liveTailRows = band.previewRows;
84
+ const topRows = band.bandRows;
37
85
  return {
38
- rows: base.rows, cols: base.cols, contentCols, inactive: false,
86
+ rows: base.rows, cols: base.cols, contentCols, inactive: false, headerRows,
39
87
  statusRows: base.statusRows, composerRows: base.composerRows,
40
88
  composerSlotRows: base.composerSlotRows, composerCols: base.composerCols,
41
89
  popupRows: base.popupRows, popupMaxHeight: base.popupMaxHeight, overlayRows,
42
90
  liveTailRows, topRows, liveRows,
91
+ band, previewCols: band.previewCols,
43
92
  };
44
93
  }