@aragon-agent/cli 0.6.2 → 0.6.3

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 (118) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +202 -0
  3. package/dist/agent/controller.d.ts +74 -1
  4. package/dist/agent/controller.js +200 -1
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +120 -0
  8. package/dist/agent/reducer.js +201 -2
  9. package/dist/cli.js +224 -5
  10. package/dist/commands/builtins.js +73 -23
  11. package/dist/commands/registry.d.ts +27 -0
  12. package/dist/compaction/archive.d.ts +175 -0
  13. package/dist/compaction/archive.js +267 -0
  14. package/dist/compaction/child.d.ts +97 -0
  15. package/dist/compaction/child.js +113 -0
  16. package/dist/compaction/command.d.ts +41 -0
  17. package/dist/compaction/command.js +342 -0
  18. package/dist/compaction/compactor.d.ts +268 -0
  19. package/dist/compaction/compactor.js +689 -0
  20. package/dist/compaction/digest.d.ts +62 -0
  21. package/dist/compaction/digest.js +164 -0
  22. package/dist/compaction/limits.d.ts +165 -0
  23. package/dist/compaction/limits.js +172 -0
  24. package/dist/compaction/pressure.d.ts +135 -0
  25. package/dist/compaction/pressure.js +167 -0
  26. package/dist/compaction/summarize-call.d.ts +70 -0
  27. package/dist/compaction/summarize-call.js +86 -0
  28. package/dist/compaction/summary-prompt.d.ts +85 -0
  29. package/dist/compaction/summary-prompt.js +197 -0
  30. package/dist/compaction/tail-budget.d.ts +52 -0
  31. package/dist/compaction/tail-budget.js +67 -0
  32. package/dist/compaction/types.d.ts +179 -0
  33. package/dist/compaction/types.js +22 -0
  34. package/dist/compaction/wiring.d.ts +255 -0
  35. package/dist/compaction/wiring.js +556 -0
  36. package/dist/config/app-paths.d.ts +14 -0
  37. package/dist/config/app-paths.js +16 -0
  38. package/dist/config/cli-commands.d.ts +41 -0
  39. package/dist/config/cli-commands.js +77 -1
  40. package/dist/config/env.d.ts +4 -1
  41. package/dist/config/env.js +92 -2
  42. package/dist/config/load.d.ts +31 -0
  43. package/dist/config/load.js +63 -1
  44. package/dist/config/schema.d.ts +159 -0
  45. package/dist/config/schema.js +137 -0
  46. package/dist/config/store.js +17 -1
  47. package/dist/config/ui-state.d.ts +28 -1
  48. package/dist/config/ui-state.js +9 -0
  49. package/dist/exec/events.d.ts +50 -1
  50. package/dist/exec/runner.js +42 -0
  51. package/dist/input/mouse-events.d.ts +31 -5
  52. package/dist/input/mouse-events.js +86 -27
  53. package/dist/input/stdin-mouse-filter.d.ts +8 -3
  54. package/dist/logging/install.d.ts +51 -1
  55. package/dist/logging/install.js +28 -0
  56. package/dist/logging/logger.d.ts +1 -1
  57. package/dist/session/persist.js +19 -0
  58. package/dist/team/report.js +7 -1
  59. package/dist/team/runtime.d.ts +10 -0
  60. package/dist/team/runtime.js +7 -0
  61. package/dist/team/subagent.d.ts +31 -1
  62. package/dist/team/subagent.js +42 -15
  63. package/dist/team/types.d.ts +8 -0
  64. package/dist/ui/ActivityLine.d.ts +19 -1
  65. package/dist/ui/ActivityLine.js +10 -2
  66. package/dist/ui/App.d.ts +48 -1
  67. package/dist/ui/App.js +294 -13
  68. package/dist/ui/Composer.d.ts +13 -1
  69. package/dist/ui/Composer.js +2 -2
  70. package/dist/ui/PromptInput.d.ts +34 -1
  71. package/dist/ui/PromptInput.js +54 -3
  72. package/dist/ui/StatusBar.d.ts +40 -0
  73. package/dist/ui/StatusBar.js +13 -4
  74. package/dist/ui/TeamPanel.js +7 -1
  75. package/dist/ui/Transcript.d.ts +21 -1
  76. package/dist/ui/Transcript.js +32 -2
  77. package/dist/ui/clipboard.d.ts +52 -0
  78. package/dist/ui/clipboard.js +91 -0
  79. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  80. package/dist/ui/entries/CompactionCard.js +158 -0
  81. package/dist/ui/frame-differ.d.ts +46 -0
  82. package/dist/ui/frame-differ.js +94 -10
  83. package/dist/ui/gauge.d.ts +16 -1
  84. package/dist/ui/gauge.js +25 -6
  85. package/dist/ui/glyphs.d.ts +11 -0
  86. package/dist/ui/glyphs.js +2 -0
  87. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  88. package/dist/ui/layout/ScrollViewport.js +128 -21
  89. package/dist/ui/layout/budget.d.ts +10 -3
  90. package/dist/ui/layout/budget.js +10 -3
  91. package/dist/ui/layout/follow-state.d.ts +137 -0
  92. package/dist/ui/layout/follow-state.js +150 -0
  93. package/dist/ui/layout/virtual-window.d.ts +11 -0
  94. package/dist/ui/layout/virtual-window.js +34 -0
  95. package/dist/ui/overlays/HelpOverlay.js +6 -1
  96. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  97. package/dist/ui/overlays/SettingsScreen.js +60 -1
  98. package/dist/ui/palettes.d.ts +16 -0
  99. package/dist/ui/palettes.js +6 -0
  100. package/dist/ui/screen.d.ts +22 -0
  101. package/dist/ui/screen.js +70 -8
  102. package/dist/ui/selection/highlight.d.ts +39 -0
  103. package/dist/ui/selection/highlight.js +64 -0
  104. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  105. package/dist/ui/selection/screen-mirror.js +35 -0
  106. package/dist/ui/selection/selection-controller.d.ts +88 -0
  107. package/dist/ui/selection/selection-controller.js +282 -0
  108. package/dist/ui/selection/selection.d.ts +90 -0
  109. package/dist/ui/selection/selection.js +223 -0
  110. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  111. package/dist/ui/stdout-frame-writer.js +15 -0
  112. package/dist/ui/theme.d.ts +11 -0
  113. package/dist/ui/theme.js +2 -0
  114. package/dist/ui/transcript-text.js +23 -0
  115. package/dist/ui/use-startup-notices.d.ts +27 -1
  116. package/dist/ui/use-startup-notices.js +43 -11
  117. package/dist/ui/use-wheel-routing.js +8 -0
  118. package/package.json +4 -2
@@ -12,34 +12,132 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  * *display* number back. Holding the offset in the parent as well would create a
13
13
  * measure → setState → re-render → measure loop whose convergence is provable
14
14
  * but whose frame count is not — expensive inside a 33 ms streaming window.
15
+ *
16
+ * AS OF tui-selection-and-scroll-follow THIS IS AN ADAPTER, NOT A POLICY OWNER.
17
+ * The two follow rules are pure and live in `follow-state.ts`; what stays here is
18
+ * measure → compute a tail delta → apply → arm a timer. `scroll.ts` is
19
+ * unchanged, so every existing scroll test keeps passing (R-10).
20
+ *
21
+ * The "N new lines · PgDn" hint used to take this viewport's LAST ROW. It now
22
+ * renders as a chip inside the composer's bordered input row (§4.2), which costs
23
+ * zero rows and removes the feedback loop where the hint's own presence shrank
24
+ * the viewport, which changed `overflowLines`, which was the number the hint
25
+ * displayed. The data still leaves through `onScrolledLinesChange`.
15
26
  */
16
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
17
- import { Box, Text, measureElement, useStdout } from 'ink';
18
- import { pickGlyphs } from '../glyphs.js';
27
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
28
+ import { Box, measureElement, useStdout } from 'ink';
19
29
  import { ViewportGeometryContext } from './viewport-geometry.js';
20
30
  import { applyScrollTimes, clampScroll, } from './scroll.js';
31
+ import { reduceFollow, shouldArmResume } from './follow-state.js';
21
32
  import { thumbRange } from './scroll-indicator.js';
22
33
  import { MIN_INDICATOR_COLS, ScrollIndicator } from './ScrollIndicator.js';
23
- /** Below this the full hint sentence wraps and eats a second viewport row. */
24
- const TERSE_HINT_COLS = 42;
25
- export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesChange, showScrollIndicator = false, cols: colsProp, theme, caps, children, }) {
34
+ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesChange, onViewportShiftChange, tailRowsRef, hold = false, resumeMs = 0, showScrollIndicator = false, cols: colsProp, theme, caps, children, }) {
26
35
  const { stdout } = useStdout();
27
36
  const cols = colsProp ?? stdout?.columns ?? 80;
28
- const glyphs = pickGlyphs(caps);
29
37
  const clipRef = useRef(null);
30
38
  const innerRef = useRef(null);
31
39
  const [metrics, setMetrics] = useState({ viewport: 0, content: 0 });
32
40
  const [scroll, setScroll] = useState({ offset: 0 });
41
+ const overflowLines = Math.max(0, metrics.content - metrics.viewport);
42
+ const offset = clampScroll(scroll.offset, overflowLines);
43
+ const shiftUp = overflowLines - offset;
33
44
  const metricsRef = useRef(metrics);
34
45
  metricsRef.current = metrics;
35
46
  const intentRef = useRef(intent);
36
47
  intentRef.current = intent;
48
+ /**
49
+ * The RENDERED (clamped) offset, assigned during render (P1-9).
50
+ *
51
+ * `scroll.offset` is raw state while everything on screen derives from
52
+ * `clampScroll(...)`; the two diverge after any content shrink (`/clear`, the
53
+ * retain ring, a re-wrap), and an accumulation against the raw value then yanks
54
+ * a viewport the user believes is pinned and shows a phantom chip.
55
+ */
56
+ const offsetRef = useRef(offset);
57
+ offsetRef.current = offset;
58
+ /** Read at timer FIRE time, not only at ARM time (I-6). */
59
+ const holdRef = useRef(hold);
60
+ holdRef.current = hold;
61
+ const resumeMsRef = useRef(resumeMs);
62
+ resumeMsRef.current = resumeMs;
63
+ /** Rows that arrived while paused; resets to 0 whenever the offset reaches 0. */
64
+ const newLinesRef = useRef(0);
65
+ const prevTail = useRef(0);
66
+ const resumeTimer = useRef(null);
67
+ const clearResumeTimer = useCallback(() => {
68
+ if (!resumeTimer.current)
69
+ return;
70
+ clearTimeout(resumeTimer.current);
71
+ resumeTimer.current = null;
72
+ }, []);
73
+ /**
74
+ * Rule B. Called from exactly three places and no others: the intent effect
75
+ * (after applying a scroll), the anchoring effect (after a non-zero
76
+ * `tailDelta`), and every `hold` transition.
77
+ *
78
+ * The re-check inside the callback is the same class of bug the wheel router's
79
+ * flush already guards: the decision that goes stale is the one taken when the
80
+ * timer was ARMED, and 5 seconds is an eternity next to a 16 ms coalescing
81
+ * window.
82
+ */
83
+ const armResume = useCallback(() => {
84
+ clearResumeTimer();
85
+ if (!shouldArmResume({
86
+ resumeMs: resumeMsRef.current,
87
+ offset: offsetRef.current,
88
+ newLinesWhilePaused: newLinesRef.current,
89
+ hold: holdRef.current,
90
+ })) {
91
+ return;
92
+ }
93
+ const timer = setTimeout(() => {
94
+ resumeTimer.current = null;
95
+ if (holdRef.current)
96
+ return; // I-6 — re-read at FIRE time.
97
+ setScroll({ offset: 0 });
98
+ }, resumeMsRef.current);
99
+ timer.unref?.();
100
+ resumeTimer.current = timer;
101
+ }, [clearResumeTimer]);
102
+ // A timer that outlives the component would fire `setScroll` on an unmounted
103
+ // tree; an overlay unmounts this component on every open.
104
+ useEffect(() => clearResumeTimer, [clearResumeTimer]);
37
105
  // Ink calculates yoga layout in `onRender`, which React runs BEFORE layout
38
106
  // effects, so the measurement below reflects the commit that just happened.
107
+ // The children have already rendered, so `tailRowsRef` is current too.
39
108
  useLayoutEffect(() => {
40
109
  const viewport = clipRef.current ? measureElement(clipRef.current).height : 0;
41
110
  const content = innerRef.current ? measureElement(innerRef.current).height : 0;
42
111
  setMetrics((prev) => (prev.viewport === viewport && prev.content === content ? prev : { viewport, content }));
112
+ // RULE A. The input is the transcript's TAIL counter and never `content`
113
+ // (P0-1 / I-7) — see the header of `follow-state.ts` for the four things
114
+ // that move a measured height and the one that must move the offset.
115
+ const tail = tailRowsRef?.current.rows ?? 0;
116
+ const tailDelta = tail - prevTail.current;
117
+ prevTail.current = tail;
118
+ if (tailDelta === 0)
119
+ return; // the steady state, and R-8's loop guard
120
+ const overflow = Math.max(0, content - viewport);
121
+ // THE DECISION IS TAKEN OUTSIDE THE UPDATER (P1-9). React is free to invoke
122
+ // an updater more than once, and a `newLinesWhilePaused += delta` inside one
123
+ // double-counts when it does.
124
+ const out = reduceFollow({
125
+ offset: clampScroll(offsetRef.current, overflow),
126
+ overflowLines: overflow,
127
+ tailDelta,
128
+ hold: holdRef.current,
129
+ newLinesWhilePaused: newLinesRef.current,
130
+ });
131
+ newLinesRef.current = out.newLinesWhilePaused;
132
+ if (out.offset !== offsetRef.current) {
133
+ // Assigned eagerly so the `armResume()` below decides against the offset
134
+ // this frame is about to render, not the one it replaced. `out.offset` is
135
+ // already clamped against the freshly measured overflow, which is exactly
136
+ // what the next render will clamp against.
137
+ offsetRef.current = out.offset;
138
+ setScroll({ offset: out.offset });
139
+ }
140
+ armResume();
43
141
  });
44
142
  // I-9: seeded from the INCOMING nonce during the first render, so the effect
45
143
  // below cannot apply an intent on the mount that first observes it. A
@@ -61,6 +159,9 @@ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesCh
61
159
  lastAppliedNonce.current = current.nonce;
62
160
  const repeat = current.repeat ?? 1;
63
161
  setScroll((prev) => applyScrollTimes(prev, current.kind, metricsRef.current, repeat));
162
+ // T-21 — "用户没动" is measured from the last GESTURE, so every applied
163
+ // intent restarts the countdown.
164
+ armResume();
64
165
  // Keyed on the nonce alone: `intent` is a fresh object on every parent
65
166
  // render, so depending on it directly would re-scroll on every keystroke.
66
167
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -68,16 +169,28 @@ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesCh
68
169
  useEffect(() => {
69
170
  setScroll({ offset: 0 });
70
171
  }, [pinToBottomNonce]);
71
- const overflowLines = Math.max(0, metrics.content - metrics.viewport);
72
- const offset = clampScroll(scroll.offset, overflowLines);
73
- const shiftUp = overflowLines - offset;
74
172
  useEffect(() => {
75
173
  onScrolledLinesChange?.(offset);
76
174
  }, [offset, onScrolledLinesChange]);
77
- // `offset` is exactly the count of rows hidden below the viewport — that is
78
- // what "N new lines you have not scrolled down to" means.
79
- const hiddenBelow = offset;
80
- const showHint = hiddenBelow > 0;
175
+ useEffect(() => {
176
+ onViewportShiftChange?.(shiftUp);
177
+ }, [shiftUp, onViewportShiftChange]);
178
+ // Reaching the bottom BY ANY ROUTE — the timer, `pinToBottomNonce`, PgDn, or
179
+ // `applyScroll`'s bottom snap — resets the paused counter and disarms. The
180
+ // anchoring route expresses the same reset inside `reduceFollow`, so the rule
181
+ // is stated once per route rather than once per gesture.
182
+ useEffect(() => {
183
+ if (offset !== 0)
184
+ return;
185
+ newLinesRef.current = 0;
186
+ armResume(); // clears; re-arming is refused at offset 0 by condition 1.
187
+ }, [offset, armResume]);
188
+ // Both `hold` edges. Rising suspends the countdown (condition 3); falling
189
+ // restarts it, which is what makes a released drag behave like any other
190
+ // finished gesture.
191
+ useEffect(() => {
192
+ armResume();
193
+ }, [hold, armResume]);
81
194
  // Suppressed below `MIN_INDICATOR_COLS`, where a column of content is worth
82
195
  // more than the affordance. The routing never reads this flag, which is what
83
196
  // keeps the indicator independently revertible (§13-Q3).
@@ -87,11 +200,5 @@ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesCh
87
200
  // value hands them down without adding a second owner or a second measure
88
201
  // pass, which is exactly what this component's header (P1-7) forbids.
89
202
  const geometry = useMemo(() => ({ viewportRows: metrics.viewport, offset, contentRows: metrics.content }), [metrics.viewport, metrics.content, offset]);
90
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Box, { ref: clipRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Box, { ref: innerRef, flexDirection: "column", flexShrink: 0, marginTop: -shiftUp, children: _jsx(ViewportGeometryContext.Provider, { value: geometry, children: children }) }) }), indicatorVisible && (_jsx(ScrollIndicator, { rows: metrics.viewport, thumb: thumbRange(metrics.viewport, metrics.content, offset), theme: theme, caps: caps }))] }), showHint && (
91
- // Not an overlay — Ink has no absolute positioning or z-index. This takes
92
- // the viewport's last row, trading one line of content for a hint that
93
- // cannot drift. Cheaper than stacking with negative margins.
94
- _jsx(Box, { flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: cols < TERSE_HINT_COLS
95
- ? `${glyphs.arrowDown}${hiddenBelow}`
96
- : `${glyphs.arrowDown} ${hiddenBelow} new ${hiddenBelow === 1 ? 'line' : 'lines'} ${glyphs.midDot} PgDn for the latest` }) }))] }));
203
+ return (_jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Box, { ref: clipRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Box, { ref: innerRef, flexDirection: "column", flexShrink: 0, marginTop: -shiftUp, children: _jsx(ViewportGeometryContext.Provider, { value: geometry, children: children }) }) }), indicatorVisible && (_jsx(ScrollIndicator, { rows: metrics.viewport, thumb: thumbRange(metrics.viewport, metrics.content, offset), theme: theme, caps: caps }))] }) }));
97
204
  }
@@ -12,10 +12,17 @@
12
12
  * Deliberately NOT part of `frame.ts`: that file is the read-only foundation
13
13
  * (invariant I-1) and this is content-layer policy.
14
14
  *
15
- * Scope: the STATIC chrome only. Two consumers are dynamic and are handled by
16
- * the degradation ladder in §5.2 instead of being averaged in here:
17
- * - `ScrollViewport`'s "N new lines" hint takes the viewport's own last row;
15
+ * Scope: the STATIC chrome only. One consumer is dynamic and is handled by the
16
+ * degradation ladder in §5.2 instead of being averaged in here:
18
17
  * - `AutocompletePopup` expands the bottom chrome by up to 9 rows.
18
+ *
19
+ * `ScrollViewport`'s "N new lines" hint used to be listed here as a second
20
+ * dynamic consumer. It no longer exists: the number renders as a chip on the
21
+ * composer's input row (tui-selection-and-scroll-follow §4.2), which costs
22
+ * COLUMNS rather than rows. The arithmetic below is unchanged — the hint was
23
+ * never in the budget in the first place — but leaving the bullet in place would
24
+ * make this comment a true-sounding statement about a row that is gone (AC-12,
25
+ * and the `wheel-scrolls-transcript-only` RV-3 lesson).
19
26
  */
20
27
  /** Below this many rows the composer's hint line is dropped to buy back a row. */
21
28
  export declare const HINT_MIN_ROWS = 20;
@@ -12,10 +12,17 @@
12
12
  * Deliberately NOT part of `frame.ts`: that file is the read-only foundation
13
13
  * (invariant I-1) and this is content-layer policy.
14
14
  *
15
- * Scope: the STATIC chrome only. Two consumers are dynamic and are handled by
16
- * the degradation ladder in §5.2 instead of being averaged in here:
17
- * - `ScrollViewport`'s "N new lines" hint takes the viewport's own last row;
15
+ * Scope: the STATIC chrome only. One consumer is dynamic and is handled by the
16
+ * degradation ladder in §5.2 instead of being averaged in here:
18
17
  * - `AutocompletePopup` expands the bottom chrome by up to 9 rows.
18
+ *
19
+ * `ScrollViewport`'s "N new lines" hint used to be listed here as a second
20
+ * dynamic consumer. It no longer exists: the number renders as a chip on the
21
+ * composer's input row (tui-selection-and-scroll-follow §4.2), which costs
22
+ * COLUMNS rather than rows. The arithmetic below is unchanged — the hint was
23
+ * never in the budget in the first place — but leaving the bullet in place would
24
+ * make this comment a true-sounding statement about a row that is gone (AC-12,
25
+ * and the `wheel-scrolls-transcript-only` RV-3 lesson).
19
26
  */
20
27
  import { frameHeight, MIN_FULLSCREEN_ROWS } from './frame.js';
21
28
  /** Below this many rows the composer's hint line is dropped to buy back a row. */
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Follow rules for the self-drawn viewport (tui-selection-and-scroll-follow
3
+ * §4.3). PURE, React-free, unit-tested — the shape `scroll.ts`,
4
+ * `virtual-window.ts` and `frame-differ.ts` all already use.
5
+ *
6
+ * It lives here rather than inside `ScrollViewport` because
7
+ * `ink-testing-library`'s stdout stub reports no height, so a MOUNTED viewport
8
+ * measures `content === viewport`, `overflowLines === 0`, and neither rule below
9
+ * ever engages (`__tests__/render-at-width.ts`, and `transcript-virtual.test.tsx`
10
+ * records the same limitation and the same workaround). A state machine that can
11
+ * only be exercised through a box the test cannot size is a state machine with
12
+ * no tests (P1-10).
13
+ *
14
+ * ═══════════════════════════════════════════════════════════════════════════
15
+ * NOTHING IN THIS FILE MAY READ A MEASUREMENT (AC-17, and it is a `grep`).
16
+ *
17
+ * The gate is a `grep` for the name of Ink's element-measuring helper returning
18
+ * NOTHING from this file, so that name is deliberately left unspelled here and
19
+ * below, even where writing it would read more directly. A grep a COMMENT can
20
+ * trip is a grep whoever runs it learns to ignore, and then the import it exists
21
+ * to catch walks straight past it — the same reason `glyphs.test.ts` strips
22
+ * comments before judging a line.
23
+ *
24
+ * The transcript's MEASURED CONTENT HEIGHT — what `ScrollViewport` reads out of
25
+ * Ink's layout for the inner box — is the height of everything the transcript is
26
+ * currently drawing, and FOUR different things move it — only one of which is
27
+ * "the agent produced output":
28
+ *
29
+ * 1. rows appended at the tail <- Rule A must react
30
+ * 2. an entry ABOVE the reading position changing <- must NOT move
31
+ * height (Ctrl+T, Ctrl+O, a first real measurement
32
+ * of an entry that stood on an estimate)
33
+ * 3. the scroll horizon dropping entries off the <- must NOT move
34
+ * FRONT (`Transcript.tsx`, `entries.slice(-size)`)
35
+ * 4. a re-wrap after a resize <- must NOT move
36
+ *
37
+ * For (2) and (3) `virtual-window.ts`'s invariant V-4 is explicit: counting the
38
+ * offset FROM THE BOTTOM makes an above-the-viewport height change
39
+ * self-compensating, and that is what buys virtualisation its "no
40
+ * scroll-anchoring compensation pass required". Reacting to total content growth
41
+ * would make Rule A that compensation pass, applied to changes that never needed
42
+ * compensating — and it would additionally make (3) look like new output and arm
43
+ * the idle timer on a transcript that produced nothing, the exact failure G6
44
+ * exists to prevent.
45
+ *
46
+ * There is a second, sharper reason. `selectWindow` TAKES `offset` AS AN INPUT,
47
+ * so the mounted set — and therefore which entries are measured rather than
48
+ * estimated — is a function of the offset. An offset that is in turn a function
49
+ * of the measured height closes a loop: offset -> mounted set -> measured
50
+ * heights -> content height -> offset. The loop is broken by the INPUT rather
51
+ * than by a convergence argument: `advanceTailRows` reads the height table and
52
+ * never the geometry context (P0-1 / D-13).
53
+ * ═══════════════════════════════════════════════════════════════════════════
54
+ */
55
+ export interface FollowInput {
56
+ /** Current RENDERED (already clamped) offset — see the caller's P1-9 note. */
57
+ offset: number;
58
+ overflowLines: number;
59
+ /** Signed: rows appended at (or removed from) the tail since the last frame. */
60
+ tailDelta: number;
61
+ /** True while a selection drag holds the viewport (§4.4). */
62
+ hold: boolean;
63
+ newLinesWhilePaused: number;
64
+ }
65
+ export interface FollowOutput {
66
+ offset: number;
67
+ newLinesWhilePaused: number;
68
+ }
69
+ /**
70
+ * Rule A. When rows are appended AT THE TAIL while the user is not pinned to the
71
+ * bottom, grow the offset by the same amount: `shiftUp = overflowLines - offset`
72
+ * is then constant and the rows on screen do not move.
73
+ *
74
+ * SIGNED, NOT GROWTH-ONLY (I-7). A tail SHRINK is rows that no longer exist
75
+ * below the reading position, so shrinking the offset by the same amount is what
76
+ * keeps the screen still — and it is self-correcting when the source of the
77
+ * delta was an upper-bound height estimate that a later measurement brings down
78
+ * (`estimateEntryRows` is deliberately an upper bound). An unsigned rule
79
+ * over-counts once and never finds its way back.
80
+ */
81
+ export declare function reduceFollow(input: FollowInput): FollowOutput;
82
+ export interface ResumeInput {
83
+ /** `scrollResumeMs`; `0` (or anything non-finite) disables auto-resume. */
84
+ resumeMs: number;
85
+ offset: number;
86
+ newLinesWhilePaused: number;
87
+ hold: boolean;
88
+ }
89
+ /**
90
+ * Rule B's three conditions, and they are three rather than one on purpose:
91
+ *
92
+ * 1. `offset > 0` — there is somewhere to return FROM;
93
+ * 2. `newLines > 0` — something arrived that is worth returning TO. THIS IS
94
+ * G6: a user reading a FINISHED transcript is never yanked to the bottom,
95
+ * which is the new bug that a timing-only rule would ship wearing the fix's
96
+ * clothes (D-3);
97
+ * 3. `!hold` — no selection drag is in progress (§4.4).
98
+ *
99
+ * AC-9 rides on the first line: with `scrollResumeMs: 0` this returns `false`
100
+ * before anything else is considered, so no timer is ever created.
101
+ */
102
+ export declare function shouldArmResume(input: ResumeInput): boolean;
103
+ /**
104
+ * What the transcript publishes through a ref written during render.
105
+ *
106
+ * `lastHeight` is not decoration: §4.3.1a defines the per-frame delta as "heights
107
+ * of entries after the previously-last entry PLUS the delta of that entry's own
108
+ * height (a streaming entry grows in place)", and the second term cannot be
109
+ * computed without remembering what that height was.
110
+ */
111
+ export interface TailState {
112
+ /** Monotonic-ish cumulative count of rows produced at the tail. Signed deltas. */
113
+ rows: number;
114
+ /** Identity anchor — the last entry's id when this state was written. */
115
+ lastId: string | null;
116
+ /** That entry's height when this state was written. */
117
+ lastHeight: number;
118
+ }
119
+ export interface TailSink {
120
+ current: TailState;
121
+ }
122
+ export declare function emptyTailState(): TailState;
123
+ /**
124
+ * Advance the tail counter by one frame.
125
+ *
126
+ * `ids` are the entries the transcript is drawing, in order; `heightOf(index)`
127
+ * is the transcript's own per-entry height table (measured where it exists, an
128
+ * upper-bound estimate where it does not). NO GEOMETRY IS READ HERE, which is
129
+ * what breaks P0-1's loop by construction.
130
+ *
131
+ * RE-ANCHORING CONTRIBUTES ZERO (R-11). When the scroll horizon drops the entry
132
+ * this counter was anchored on, the anchor is simply moved to the new last entry
133
+ * and the frame contributes a delta of 0. Losing one frame of anchoring is a row
134
+ * of drift at worst; treating a FRONT-DROP as tail motion is a visible jump, and
135
+ * an unbounded one.
136
+ */
137
+ export declare function advanceTailRows(prev: TailState, ids: readonly string[], heightOf: (index: number) => number): TailState;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Follow rules for the self-drawn viewport (tui-selection-and-scroll-follow
3
+ * §4.3). PURE, React-free, unit-tested — the shape `scroll.ts`,
4
+ * `virtual-window.ts` and `frame-differ.ts` all already use.
5
+ *
6
+ * It lives here rather than inside `ScrollViewport` because
7
+ * `ink-testing-library`'s stdout stub reports no height, so a MOUNTED viewport
8
+ * measures `content === viewport`, `overflowLines === 0`, and neither rule below
9
+ * ever engages (`__tests__/render-at-width.ts`, and `transcript-virtual.test.tsx`
10
+ * records the same limitation and the same workaround). A state machine that can
11
+ * only be exercised through a box the test cannot size is a state machine with
12
+ * no tests (P1-10).
13
+ *
14
+ * ═══════════════════════════════════════════════════════════════════════════
15
+ * NOTHING IN THIS FILE MAY READ A MEASUREMENT (AC-17, and it is a `grep`).
16
+ *
17
+ * The gate is a `grep` for the name of Ink's element-measuring helper returning
18
+ * NOTHING from this file, so that name is deliberately left unspelled here and
19
+ * below, even where writing it would read more directly. A grep a COMMENT can
20
+ * trip is a grep whoever runs it learns to ignore, and then the import it exists
21
+ * to catch walks straight past it — the same reason `glyphs.test.ts` strips
22
+ * comments before judging a line.
23
+ *
24
+ * The transcript's MEASURED CONTENT HEIGHT — what `ScrollViewport` reads out of
25
+ * Ink's layout for the inner box — is the height of everything the transcript is
26
+ * currently drawing, and FOUR different things move it — only one of which is
27
+ * "the agent produced output":
28
+ *
29
+ * 1. rows appended at the tail <- Rule A must react
30
+ * 2. an entry ABOVE the reading position changing <- must NOT move
31
+ * height (Ctrl+T, Ctrl+O, a first real measurement
32
+ * of an entry that stood on an estimate)
33
+ * 3. the scroll horizon dropping entries off the <- must NOT move
34
+ * FRONT (`Transcript.tsx`, `entries.slice(-size)`)
35
+ * 4. a re-wrap after a resize <- must NOT move
36
+ *
37
+ * For (2) and (3) `virtual-window.ts`'s invariant V-4 is explicit: counting the
38
+ * offset FROM THE BOTTOM makes an above-the-viewport height change
39
+ * self-compensating, and that is what buys virtualisation its "no
40
+ * scroll-anchoring compensation pass required". Reacting to total content growth
41
+ * would make Rule A that compensation pass, applied to changes that never needed
42
+ * compensating — and it would additionally make (3) look like new output and arm
43
+ * the idle timer on a transcript that produced nothing, the exact failure G6
44
+ * exists to prevent.
45
+ *
46
+ * There is a second, sharper reason. `selectWindow` TAKES `offset` AS AN INPUT,
47
+ * so the mounted set — and therefore which entries are measured rather than
48
+ * estimated — is a function of the offset. An offset that is in turn a function
49
+ * of the measured height closes a loop: offset -> mounted set -> measured
50
+ * heights -> content height -> offset. The loop is broken by the INPUT rather
51
+ * than by a convergence argument: `advanceTailRows` reads the height table and
52
+ * never the geometry context (P0-1 / D-13).
53
+ * ═══════════════════════════════════════════════════════════════════════════
54
+ */
55
+ import { clampScroll } from './scroll.js';
56
+ /**
57
+ * Rule A. When rows are appended AT THE TAIL while the user is not pinned to the
58
+ * bottom, grow the offset by the same amount: `shiftUp = overflowLines - offset`
59
+ * is then constant and the rows on screen do not move.
60
+ *
61
+ * SIGNED, NOT GROWTH-ONLY (I-7). A tail SHRINK is rows that no longer exist
62
+ * below the reading position, so shrinking the offset by the same amount is what
63
+ * keeps the screen still — and it is self-correcting when the source of the
64
+ * delta was an upper-bound height estimate that a later measurement brings down
65
+ * (`estimateEntryRows` is deliberately an upper bound). An unsigned rule
66
+ * over-counts once and never finds its way back.
67
+ */
68
+ export function reduceFollow(input) {
69
+ const { offset, overflowLines, tailDelta, hold } = input;
70
+ // Pinned and not holding: anchoring is free — `offset` stays 0 and the
71
+ // existing arithmetic keeps showing the tail. `hold` LIFTS the precondition
72
+ // because a drag that starts while pinned must still freeze the rows under the
73
+ // pointer (S3), and freezing means the pin becomes an anchor for the duration
74
+ // of the drag (T-20).
75
+ if (offset === 0 && !hold)
76
+ return { offset, newLinesWhilePaused: 0 };
77
+ if (tailDelta === 0)
78
+ return { offset, newLinesWhilePaused: input.newLinesWhilePaused };
79
+ const next = clampScroll(offset + tailDelta, overflowLines);
80
+ return {
81
+ offset: next,
82
+ // Only real arrivals arm the resume timer, and reaching the bottom by ANY
83
+ // route resets the count (Rule B condition 2 / G6). Expressing the reset
84
+ // here is what keeps it one rule rather than two.
85
+ newLinesWhilePaused: next === 0 ? 0 : Math.max(0, input.newLinesWhilePaused + tailDelta),
86
+ };
87
+ }
88
+ /**
89
+ * Rule B's three conditions, and they are three rather than one on purpose:
90
+ *
91
+ * 1. `offset > 0` — there is somewhere to return FROM;
92
+ * 2. `newLines > 0` — something arrived that is worth returning TO. THIS IS
93
+ * G6: a user reading a FINISHED transcript is never yanked to the bottom,
94
+ * which is the new bug that a timing-only rule would ship wearing the fix's
95
+ * clothes (D-3);
96
+ * 3. `!hold` — no selection drag is in progress (§4.4).
97
+ *
98
+ * AC-9 rides on the first line: with `scrollResumeMs: 0` this returns `false`
99
+ * before anything else is considered, so no timer is ever created.
100
+ */
101
+ export function shouldArmResume(input) {
102
+ if (!Number.isFinite(input.resumeMs) || input.resumeMs <= 0)
103
+ return false;
104
+ if (input.offset <= 0)
105
+ return false;
106
+ if (input.newLinesWhilePaused <= 0)
107
+ return false;
108
+ if (input.hold)
109
+ return false;
110
+ return true;
111
+ }
112
+ export function emptyTailState() {
113
+ return { rows: 0, lastId: null, lastHeight: 0 };
114
+ }
115
+ /**
116
+ * Advance the tail counter by one frame.
117
+ *
118
+ * `ids` are the entries the transcript is drawing, in order; `heightOf(index)`
119
+ * is the transcript's own per-entry height table (measured where it exists, an
120
+ * upper-bound estimate where it does not). NO GEOMETRY IS READ HERE, which is
121
+ * what breaks P0-1's loop by construction.
122
+ *
123
+ * RE-ANCHORING CONTRIBUTES ZERO (R-11). When the scroll horizon drops the entry
124
+ * this counter was anchored on, the anchor is simply moved to the new last entry
125
+ * and the frame contributes a delta of 0. Losing one frame of anchoring is a row
126
+ * of drift at worst; treating a FRONT-DROP as tail motion is a visible jump, and
127
+ * an unbounded one.
128
+ */
129
+ export function advanceTailRows(prev, ids, heightOf) {
130
+ if (ids.length === 0)
131
+ return { rows: prev.rows, lastId: null, lastHeight: 0 };
132
+ const lastIndex = ids.length - 1;
133
+ const lastId = ids[lastIndex];
134
+ const lastHeight = heightOf(lastIndex);
135
+ if (prev.lastId === null) {
136
+ // First frame: there is no "before", so there is no delta to attribute.
137
+ return { rows: prev.rows, lastId, lastHeight };
138
+ }
139
+ const anchor = ids.lastIndexOf(prev.lastId);
140
+ if (anchor === -1) {
141
+ // The anchor is gone (horizon front-drop, `/clear`, `/reset`, `/resume`).
142
+ return { rows: prev.rows, lastId, lastHeight };
143
+ }
144
+ // The anchor entry may have grown in place (a streaming tail entry), and
145
+ // everything after it is new. Both terms are signed.
146
+ let delta = heightOf(anchor) - prev.lastHeight;
147
+ for (let i = anchor + 1; i <= lastIndex; i += 1)
148
+ delta += heightOf(i);
149
+ return { rows: prev.rows + delta, lastId, lastHeight };
150
+ }
@@ -20,6 +20,17 @@
20
20
  * `overflowLines`, while `offset` — and hence what the user is looking at — is
21
21
  * unchanged. No scroll-anchoring compensation pass is required. Do NOT
22
22
  * "simplify" `offset` to count from the top.
23
+ *
24
+ * V-4 AS AMENDED BY tui-selection-and-scroll-follow (§4.3.1a). There is now
25
+ * exactly ONE compensation pass — `follow-state.ts`'s Rule A — and it is
26
+ * deliberately restricted to rows appended AT THE TAIL, published by
27
+ * `Transcript.tsx` from the height table below. Everything the paragraph above
28
+ * describes still self-compensates and is still not compensated for again.
29
+ * The restriction is the whole of V-4's survival: an offset that reacted to
30
+ * TOTAL measured height would turn every above-the-viewport correction into a
31
+ * jump, would read a horizon front-drop as new output — and, because
32
+ * `selectWindow` takes `offset` as an INPUT, would close a loop from the offset
33
+ * through the mounted set and the measured heights back to the offset.
23
34
  */
24
35
  import type { Entry } from '../../agent/reducer.js';
25
36
  import type { DensityMode } from '../density.js';
@@ -20,8 +20,20 @@
20
20
  * `overflowLines`, while `offset` — and hence what the user is looking at — is
21
21
  * unchanged. No scroll-anchoring compensation pass is required. Do NOT
22
22
  * "simplify" `offset` to count from the top.
23
+ *
24
+ * V-4 AS AMENDED BY tui-selection-and-scroll-follow (§4.3.1a). There is now
25
+ * exactly ONE compensation pass — `follow-state.ts`'s Rule A — and it is
26
+ * deliberately restricted to rows appended AT THE TAIL, published by
27
+ * `Transcript.tsx` from the height table below. Everything the paragraph above
28
+ * describes still self-compensates and is still not compensated for again.
29
+ * The restriction is the whole of V-4's survival: an offset that reacted to
30
+ * TOTAL measured height would turn every above-the-viewport correction into a
31
+ * jump, would read a horizon front-drop as new output — and, because
32
+ * `selectWindow` takes `offset` as an INPUT, would close a loop from the offset
33
+ * through the mounted set and the measured heights back to the offset.
23
34
  */
24
35
  import { FAST_LIMITS } from '../../fast/limits.js';
36
+ import { COMPACTION_LIMITS } from '../../compaction/limits.js';
25
37
  import { GUTTER_WIDTH } from './Gutter.js';
26
38
  export const VIRTUAL_LIMITS = {
27
39
  /**
@@ -101,6 +113,16 @@ export function entryRevision(entry) {
101
113
  */
102
114
  case 'fast':
103
115
  return `f${entry.status}.${entry.live ? 1 : 0}.${entry.text?.length ?? 0}.${entry.detail?.length ?? 0}`;
116
+ /**
117
+ * `live`, the SUMMARY LENGTH and `mode`, because all three change the rows the
118
+ * card draws: a live card renders a headline and no body, a settled one
119
+ * renders the summary (whose length decides how many rows it wraps to), and
120
+ * `mode` selects between the "summarized N messages" and "dropped N WITHOUT a
121
+ * summary" detail lines. `durationMs` is inside the `wrap="truncate"`
122
+ * headline, so it can never change the height.
123
+ */
124
+ case 'compaction':
125
+ return `c${entry.live ? 1 : 0}.${entry.summary?.length ?? 0}.${entry.mode}.${entry.applied ? 1 : 0}`;
104
126
  default:
105
127
  return 'x';
106
128
  }
@@ -243,6 +265,18 @@ export function estimateEntryRows(entry, cols, density, expanded, thinkingVisibl
243
265
  : 1;
244
266
  return separation + 1 + body;
245
267
  }
268
+ // THE SECOND SWITCH, and adding the case to only one of the two yields a
269
+ // transcript whose scroll arithmetic disagrees with what it drew — drift
270
+ // rather than an error (C-9 / RV-13). Headline row, one detail row, then the
271
+ // summary clamped to `cardTextRows` (or unclamped when expanded) plus the
272
+ // `+N more lines` footer. An UPPER BOUND, which is what this function owes
273
+ // its caller.
274
+ case 'compaction': {
275
+ const lines = entry.summary ? wrappedRows(entry.summary, usable) : 0;
276
+ const body = expanded ? lines : Math.min(lines, COMPACTION_LIMITS.cardTextRows);
277
+ const footer = lines > body || (expanded && lines > COMPACTION_LIMITS.cardTextRows) ? 1 : 0;
278
+ return separation + 1 + 1 + body + footer;
279
+ }
246
280
  default:
247
281
  return separation + 1;
248
282
  }
@@ -21,6 +21,10 @@ function keyRows(times) {
21
21
  ['Shift+Up / Shift+Down', 'Scroll the transcript a line (full-screen mode)'],
22
22
  ['Wheel', 'Scroll the transcript (or the open overlay)'],
23
23
  ['Shift+Wheel', 'Scroll the transcript a page'],
24
+ // Drag-select is the one affordance here a user cannot discover by pressing
25
+ // a key, so it has to be named somewhere they can look it up. The one-shot
26
+ // startup notice is the other place, and it scrolls away.
27
+ ['Drag (left button)', 'Select text on screen; releasing copies it'],
24
28
  ['Up / Down', 'Prompt history (empty input)'],
25
29
  ];
26
30
  }
@@ -44,7 +48,8 @@ const COMMANDS = [
44
48
  ['/cwd [dir]', 'Show or change the tool working directory'],
45
49
  ['/save [file]', 'Save the session to JSON'],
46
50
  ['/resume [file]', 'Load a saved session'],
47
- ['/copy', 'Copy the last answer to the clipboard'],
51
+ ['/copy', 'Copy the last answer to the clipboard (OSC 52, so it works over ssh)'],
52
+ ['/mouse [on|off]', 'Release the mouse to your terminal, or take it back'],
48
53
  ['/exit', 'Exit'],
49
54
  ];
50
55
  const SKILL_ROWS = [