@aragon-agent/cli 0.6.7 → 0.6.8

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 (63) hide show
  1. package/README.md +72 -43
  2. package/dist/agent/controller.d.ts +10 -6
  3. package/dist/agent/controller.js +35 -48
  4. package/dist/agent/headless.d.ts +2 -1
  5. package/dist/agent/prompt-options.d.ts +15 -0
  6. package/dist/agent/prompt-options.js +1 -0
  7. package/dist/cli.js +83 -53
  8. package/dist/commands/builtins.js +3 -1
  9. package/dist/commands/registry.d.ts +2 -3
  10. package/dist/input/interrupt-gesture.d.ts +18 -0
  11. package/dist/input/interrupt-gesture.js +19 -0
  12. package/dist/input/pointer-router.d.ts +11 -0
  13. package/dist/input/pointer-router.js +14 -0
  14. package/dist/todo/store.d.ts +8 -13
  15. package/dist/todo/store.js +13 -14
  16. package/dist/todo/types.d.ts +2 -1
  17. package/dist/ui/App.d.ts +2 -0
  18. package/dist/ui/App.js +166 -128
  19. package/dist/ui/Composer.d.ts +11 -5
  20. package/dist/ui/Composer.js +7 -11
  21. package/dist/ui/PromptCaret.d.ts +11 -0
  22. package/dist/ui/PromptCaret.js +31 -0
  23. package/dist/ui/PromptInput.d.ts +9 -6
  24. package/dist/ui/PromptInput.js +34 -14
  25. package/dist/ui/StatusBar.d.ts +1 -0
  26. package/dist/ui/StatusBar.js +14 -8
  27. package/dist/ui/Transcript.js +1 -0
  28. package/dist/ui/composer-rows.js +50 -9
  29. package/dist/ui/frame-differ.js +2 -19
  30. package/dist/ui/frame-observer.d.ts +17 -0
  31. package/dist/ui/frame-observer.js +81 -0
  32. package/dist/ui/frame-parser.d.ts +7 -0
  33. package/dist/ui/frame-parser.js +18 -0
  34. package/dist/ui/layout/AppShell.d.ts +7 -52
  35. package/dist/ui/layout/AppShell.js +2 -2
  36. package/dist/ui/layout/ScrollIndicator.d.ts +4 -3
  37. package/dist/ui/layout/ScrollIndicator.js +4 -4
  38. package/dist/ui/layout/ScrollViewport.d.ts +13 -66
  39. package/dist/ui/layout/ScrollViewport.js +183 -185
  40. package/dist/ui/layout/budget.d.ts +3 -51
  41. package/dist/ui/layout/budget.js +4 -53
  42. package/dist/ui/layout/document-layout.d.ts +13 -0
  43. package/dist/ui/layout/document-layout.js +11 -0
  44. package/dist/ui/layout/follow-state.d.ts +1 -0
  45. package/dist/ui/layout/follow-state.js +1 -3
  46. package/dist/ui/layout/scroll-indicator.d.ts +1 -1
  47. package/dist/ui/layout/scroll-indicator.js +3 -2
  48. package/dist/ui/layout/scrollbar-geometry.d.ts +17 -0
  49. package/dist/ui/layout/scrollbar-geometry.js +13 -0
  50. package/dist/ui/layout/todo-layout.d.ts +1 -0
  51. package/dist/ui/layout/todo-layout.js +3 -2
  52. package/dist/ui/layout/viewport-geometry.d.ts +1 -0
  53. package/dist/ui/layout/viewport-geometry.js +1 -0
  54. package/dist/ui/layout/virtual-window.d.ts +1 -0
  55. package/dist/ui/layout/virtual-window.js +1 -1
  56. package/dist/ui/overlays/HelpOverlay.js +5 -2
  57. package/dist/ui/screen.d.ts +1 -1
  58. package/dist/ui/screen.js +4 -6
  59. package/dist/ui/scrollbar-controller.d.ts +30 -0
  60. package/dist/ui/scrollbar-controller.js +124 -0
  61. package/dist/ui/use-wheel-routing.d.ts +2 -0
  62. package/dist/ui/use-wheel-routing.js +6 -0
  63. package/package.json +2 -2
@@ -1,14 +1,4 @@
1
- /**
2
- * AppShell (spec §4.3) — the frame that makes R2 true.
3
- *
4
- * In `fullscreen` the root box has a FIXED height of `frameHeight(rows)` and the
5
- * middle viewport is the only `flexGrow` child, so the bottom chrome (toast /
6
- * composer / status bar) is physically the last thing on the screen from the
7
- * very first frame — empty session, long session, mid-scroll, overlay open.
8
- *
9
- * In `inline` the same children are emitted in the same order with no height
10
- * constraint, i.e. byte-for-byte the v0.2.0 document-flow behavior.
11
- */
1
+ /** Stable fullscreen slots preserve the editor during overlays and small resizes. */
12
2
  import React from 'react';
13
3
  import { type RenderMode } from './frame.js';
14
4
  export interface AppShellProps {
@@ -17,51 +7,16 @@ export interface AppShellProps {
17
7
  cols: number;
18
8
  header: React.ReactNode;
19
9
  viewport: React.ReactNode;
20
- /**
21
- * The live subagent roster (team-subagents §6.1), or nothing.
22
- *
23
- * IT RENDERS IN THE BOTTOM CHROME OF THE FULL-SCREEN BRANCH, above `toast` and
24
- * below the viewport: the roster is transient status about the run in progress,
25
- * so it belongs with the other transient rows rather than inside the scrolling
26
- * transcript, where it would be interleaved with history and scroll away.
27
- */
10
+ /** Legacy inline footer; fullscreen assembles these slots inside its viewport. */
28
11
  team?: React.ReactNode;
29
- /**
30
- * The right-hand region of the MIDDLE band (todo-plan-execution §3.9), or
31
- * nothing.
32
- *
33
- * NAMED FOR ITS POSITION, NOT ITS CONTENT (D-18): this file is a layout
34
- * primitive and has no business knowing what a todo is. `team` above already
35
- * leaks content vocabulary in here; there is no reason to repeat it.
36
- *
37
- * IT MUST BE RENDERED IN THE MIDDLE BAND, BESIDE THE VIEWPORT (C-6 / I-4) —
38
- * that is what makes it a rail rather than another row of bottom chrome. Moved
39
- * down into the bottom box it would stop being a column beside the transcript
40
- * and start eating viewport height, growing and shrinking the transcript every
41
- * time the list changed length.
42
- *
43
- * The INLINE branch does not include it at all — a structural guarantee rather
44
- * than a promise that the caller passes `null` (non-goal 4).
45
- */
12
+ /** Kept source-compatible; fullscreen rail is owned by ScrollViewport. */
46
13
  rail?: React.ReactNode;
47
- /**
48
- * The one-row plan strip for INLINE mode (todo-plan-followthrough §3.7), or
49
- * nothing.
50
- *
51
- * THE STRUCTURAL MIRROR OF `rail` ABOVE, in the opposite direction: the
52
- * FULL-SCREEN branch does not reference it at all. The reason is the same one
53
- * `rail`'s inline omission records — a prop that is merely SUPPOSED to be null
54
- * in one mode becomes non-null eventually, and then the rail and the strip are
55
- * both on screen showing the same list (I-6). Fullscreen has the rail; that is
56
- * the whole design.
57
- *
58
- * It sits between `viewport` and `team`, mirroring where the rail sits in the
59
- * other branch: last thing above the bottom chrome, so the strip and the
60
- * roster read top-to-bottom in the same order in both modes.
61
- */
14
+ /** Inline-only plan strip. */
62
15
  strip?: React.ReactNode;
63
16
  toast: React.ReactNode;
64
17
  composer: React.ReactNode;
65
18
  status: React.ReactNode;
19
+ inactive?: boolean;
20
+ placeholder?: React.ReactNode;
66
21
  }
67
- export declare function AppShell({ mode, rows, cols, header, viewport, team, rail, strip, toast, composer, status, }: AppShellProps): React.ReactElement;
22
+ export declare function AppShell({ mode, rows, cols, header, viewport, team, strip, toast, composer, status, inactive, placeholder, }: AppShellProps): React.ReactElement;
@@ -1,9 +1,9 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box } from 'ink';
3
3
  import { frameHeight } from './frame.js';
4
- export function AppShell({ mode, rows, cols, header, viewport, team, rail, strip, toast, composer, status, }) {
4
+ export function AppShell({ mode, rows, cols, header, viewport, team, strip, toast, composer, status, inactive = false, placeholder, }) {
5
5
  if (mode === 'inline') {
6
6
  return (_jsxs(Box, { flexDirection: "column", children: [header, viewport, strip, team, toast, composer, status] }));
7
7
  }
8
- return (_jsxs(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexShrink: 0, children: header }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: viewport }), rail] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [team, toast, composer, status] })] }));
8
+ return (_jsxs(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsxs(Box, { display: inactive ? 'none' : 'flex', flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: header }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: viewport }), _jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: toast }), _jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: status })] }), _jsx(Box, { display: inactive ? 'flex' : 'none', height: frameHeight(rows), overflow: "hidden", children: placeholder })] }));
9
9
  }
@@ -18,14 +18,15 @@ import React from 'react';
18
18
  import type { Theme } from '../theme.js';
19
19
  import type { TermCapabilities } from '../capabilities.js';
20
20
  import type { ThumbRange } from './scroll-indicator.js';
21
- /** Below this width a column of content is worth more than the affordance. */
22
- export declare const MIN_INDICATOR_COLS = 50;
21
+ /** Smallest supported fullscreen width; every usable chat frame reserves the column. */
22
+ export declare const MIN_INDICATOR_COLS = 40;
23
23
  export interface ScrollIndicatorProps {
24
24
  /** Viewport height in rows, as measured by `ScrollViewport`. */
25
25
  rows: number;
26
26
  /** `null` when the content fits — the rail is drawn as bare track. */
27
27
  thumb: ThumbRange | null;
28
+ dragging?: boolean;
28
29
  theme: Theme;
29
30
  caps: TermCapabilities;
30
31
  }
31
- export declare function ScrollIndicator({ rows, thumb, theme, caps, }: ScrollIndicatorProps): React.ReactElement;
32
+ export declare function ScrollIndicator({ rows, thumb, dragging, theme, caps, }: ScrollIndicatorProps): React.ReactElement;
@@ -1,15 +1,15 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { pickGlyphs } from '../glyphs.js';
4
- /** Below this width a column of content is worth more than the affordance. */
5
- export const MIN_INDICATOR_COLS = 50;
6
- export function ScrollIndicator({ rows, thumb, theme, caps, }) {
4
+ /** Smallest supported fullscreen width; every usable chat frame reserves the column. */
5
+ export const MIN_INDICATOR_COLS = 40;
6
+ export function ScrollIndicator({ rows, thumb, dragging = false, theme, caps, }) {
7
7
  const glyphs = pickGlyphs(caps);
8
8
  const count = Math.max(0, Math.floor(rows) || 0);
9
9
  const cells = [];
10
10
  for (let i = 0; i < count; i += 1) {
11
11
  const onThumb = !!thumb && i >= thumb.start && i < thumb.start + thumb.size;
12
- cells.push(_jsx(Text, { color: onThumb ? theme.primary : theme.border, children: onThumb ? glyphs.scrollThumb : glyphs.scrollTrack }, i));
12
+ cells.push(_jsx(Text, { color: onThumb ? theme.primary : theme.muted, bold: onThumb && dragging, children: onThumb ? glyphs.scrollThumb : glyphs.scrollTrack }, i));
13
13
  }
14
14
  return (_jsx(Box, { flexDirection: "column", flexShrink: 0, width: 1, children: cells }));
15
15
  }
@@ -1,88 +1,35 @@
1
- /**
2
- * ScrollViewport (spec §4.5) — a self-drawn scrolling window built from Ink's
3
- * negative `marginTop` + `overflow: hidden` + `measureElement`.
4
- *
5
- * Once the frame occupies the whole screen, `<Static>` history is pushed above
6
- * the frame where only one line is visible, so native scrollback stops being a
7
- * usable mechanism and the app has to own scrolling. That is why this exists.
8
- *
9
- * SINGLE SOURCE OF TRUTH (review P1-7): both the metrics and the scroll offset
10
- * live here. The parent only pushes an *intent* down and receives one derived
11
- * *display* number back. Holding the offset in the parent as well would create a
12
- * measure → setState → re-render → measure loop whose convergence is provable
13
- * but whose frame count is not — expensive inside a 33 ms streaming window.
14
- *
15
- * AS OF tui-selection-and-scroll-follow THIS IS AN ADAPTER, NOT A POLICY OWNER.
16
- * The two follow rules are pure and live in `follow-state.ts`; what stays here is
17
- * measure → compute a tail delta → apply → arm a timer. `scroll.ts` is
18
- * unchanged, so every existing scroll test keeps passing (R-10).
19
- *
20
- * The "N new lines · PgDn" hint used to take this viewport's LAST ROW. It now
21
- * renders as a chip inside the composer's bordered input row (§4.2), which costs
22
- * zero rows and removes the feedback loop where the hint's own presence shrank
23
- * the viewport, which changed `overflowLines`, which was the number the hint
24
- * displayed. The data still leaves through `onScrolledLinesChange`.
25
- */
26
1
  import React from 'react';
2
+ import { type DOMElement } from 'ink';
27
3
  import type { Theme } from '../theme.js';
28
4
  import type { TermCapabilities } from '../capabilities.js';
5
+ import { type ScrollbarBridge } from '../scrollbar-controller.js';
29
6
  import { type ScrollIntent } from './scroll.js';
30
7
  import { type TailSink } from './follow-state.js';
31
8
  export interface ScrollViewportProps {
32
- /**
33
- * Scroll intent. `nonce` is monotonic so pressing the same key twice re-fires.
34
- * `repeat` folds a coalesced wheel burst into one state update (defaults 1).
35
- */
36
9
  intent?: {
37
10
  kind: ScrollIntent;
38
11
  nonce: number;
39
12
  repeat?: number;
40
13
  };
41
- /** Bumped by the parent to force a pin to the newest output (e.g. on submit). */
42
14
  pinToBottomNonce?: number;
43
- /** Derived display value for the status bar's `↑N`. Must be referentially stable. */
15
+ resetKey?: unknown;
44
16
  onScrolledLinesChange?: (lines: number) => void;
45
- /**
46
- * Rows hidden ABOVE the window, published on every change (§4.3.3).
47
- *
48
- * PUBLISHED, NOT LIFTED — the same shape as `onScrolledLinesChange`, so there
49
- * is still exactly one owner of the offset. `shiftUp` changes exactly when the
50
- * rows on screen move, which is the property the selection's clear-list was
51
- * approximating by enumeration (I-9 / P1-4). Must be referentially stable.
52
- */
53
- onViewportShiftChange?: (shiftUp: number) => void;
54
- /**
55
- * The transcript's tail row counter (§4.3.1a), written during ITS render and
56
- * therefore already current when this component's layout effect runs.
57
- *
58
- * ABSENT ⇒ NO ANCHORING AT ALL, deliberately: every existing caller and every
59
- * existing test that does not supply it behaves exactly as it did before this
60
- * feature, because `tailDelta` is then permanently 0.
61
- */
17
+ onViewportShiftChange?: (shift: number) => void;
62
18
  tailRowsRef?: TailSink;
63
- /**
64
- * True while a selection drag is in progress (§4.4). It anchors the content
65
- * even from a pinned viewport, and it suspends the resume timer.
66
- */
67
19
  hold?: boolean;
68
- /** Idle delay before returning to the newest line, in ms; `0` disables (AC-9). */
69
20
  resumeMs?: number;
70
- /** Reserve the right-edge scroll-position rail (mouse-wheel §4.8). */
71
21
  showScrollIndicator?: boolean;
72
- /**
73
- * The viewport's own width, when it is narrower than the terminal
74
- * (todo-plan-execution §3.9 / C-7).
75
- *
76
- * This component is the ONLY one inside the viewport subtree that reads
77
- * `stdout.columns` directly, and the place it uses the number — the
78
- * `MIN_INDICATOR_COLS` gate — is about how much room THIS BOX has, not how
79
- * wide the terminal is. Once the todo rail takes its responsive share of the frame those two
80
- * stop being the same number. Optional so every existing caller (and every
81
- * test) is unchanged.
82
- */
83
22
  cols?: number;
23
+ footer?: React.ReactNode;
24
+ rail?: React.ReactNode;
25
+ overlay?: React.ReactNode;
26
+ active?: boolean;
27
+ scrollbar?: ScrollbarBridge;
28
+ composerRef?: React.RefObject<DOMElement>;
29
+ onComposerVisibilityChange?: (visible: boolean) => void;
84
30
  theme: Theme;
85
31
  caps: TermCapabilities;
86
32
  children: React.ReactNode;
87
33
  }
88
- export declare function ScrollViewport({ intent, pinToBottomNonce, onScrolledLinesChange, onViewportShiftChange, tailRowsRef, hold, resumeMs, showScrollIndicator, cols: colsProp, theme, caps, children, }: ScrollViewportProps): React.ReactElement;
34
+ /** One offset for messages, padding and editor; hidden trees keep their local state. */
35
+ export declare function ScrollViewport(props: ScrollViewportProps): React.ReactElement;
@@ -1,204 +1,202 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /**
3
- * ScrollViewport (spec §4.5) — a self-drawn scrolling window built from Ink's
4
- * negative `marginTop` + `overflow: hidden` + `measureElement`.
5
- *
6
- * Once the frame occupies the whole screen, `<Static>` history is pushed above
7
- * the frame where only one line is visible, so native scrollback stops being a
8
- * usable mechanism and the app has to own scrolling. That is why this exists.
9
- *
10
- * SINGLE SOURCE OF TRUTH (review P1-7): both the metrics and the scroll offset
11
- * live here. The parent only pushes an *intent* down and receives one derived
12
- * *display* number back. Holding the offset in the parent as well would create a
13
- * measure → setState → re-render → measure loop whose convergence is provable
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`.
26
- */
27
2
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
28
3
  import { Box, measureElement, useStdout } from 'ink';
4
+ import { createScrollbarController } from '../scrollbar-controller.js';
29
5
  import { ViewportGeometryContext } from './viewport-geometry.js';
30
- import { applyScrollTimes, clampScroll, } from './scroll.js';
6
+ import { applyScrollTimes } from './scroll.js';
31
7
  import { reduceFollow, shouldArmResume } from './follow-state.js';
32
8
  import { thumbRange } from './scroll-indicator.js';
33
- import { MIN_INDICATOR_COLS, ScrollIndicator } from './ScrollIndicator.js';
34
- export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesChange, onViewportShiftChange, tailRowsRef, hold = false, resumeMs = 0, showScrollIndicator = false, cols: colsProp, theme, caps, children, }) {
9
+ import { ScrollIndicator } from './ScrollIndicator.js';
10
+ import { buildDocumentLayout } from './document-layout.js';
11
+ function composerVisible(composer, clip) {
12
+ if (!composer || !clip)
13
+ return true;
14
+ let top = 0;
15
+ let current = composer;
16
+ while (current && current !== clip) {
17
+ top += current.yogaNode?.getComputedTop() ?? 0;
18
+ current = current.parentNode;
19
+ }
20
+ return current === clip && top >= 0 &&
21
+ top + measureElement(composer).height <= measureElement(clip).height;
22
+ }
23
+ /** One offset for messages, padding and editor; hidden trees keep their local state. */
24
+ export function ScrollViewport(props) {
35
25
  const { stdout } = useStdout();
36
- const cols = colsProp ?? stdout?.columns ?? 80;
26
+ const { active = true, hold = false, resumeMs = 0, scrollbar } = props;
27
+ const cols = props.cols ?? stdout?.columns ?? 80;
37
28
  const clipRef = useRef(null);
38
- const innerRef = useRef(null);
39
- const [metrics, setMetrics] = useState({ viewport: 0, content: 0 });
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;
44
- const metricsRef = useRef(metrics);
45
- metricsRef.current = metrics;
46
- const intentRef = useRef(intent);
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);
29
+ const bodyRef = useRef(null);
30
+ const footerRef = useRef(null);
31
+ const [{ metrics, offset }, setGeometry] = useState({
32
+ metrics: { viewport: 0, content: 0, padding: 0, footer: 0 }, offset: 0,
33
+ });
34
+ const pendingMetrics = useRef(metrics);
35
+ const geometryScheduled = useRef(false);
36
+ const mounted = useRef(true);
37
+ const [dragging, setDragging] = useState(false);
38
+ const offsetRef = useRef(0);
39
+ const baseline = useRef(null);
40
+ const latest = useRef({ active, hold, resumeMs, metrics, scrollbar });
41
+ latest.current = { active, hold, resumeMs, metrics, scrollbar };
42
+ const pausedLines = useRef(0);
66
43
  const resumeTimer = useRef(null);
67
- const clearResumeTimer = useCallback(() => {
68
- if (!resumeTimer.current)
69
- return;
70
- clearTimeout(resumeTimer.current);
44
+ const lastIntent = useRef(props.intent?.nonce);
45
+ const lastPin = useRef(props.pinToBottomNonce);
46
+ const lastReset = useRef(props.resetKey);
47
+ const lastVisible = useRef();
48
+ const previousActive = useRef(active);
49
+ const geometryRevision = useRef(0);
50
+ const geometryBounds = useRef('');
51
+ const clearResume = useCallback(() => {
52
+ if (resumeTimer.current)
53
+ clearTimeout(resumeTimer.current);
71
54
  resumeTimer.current = null;
72
55
  }, []);
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
- */
56
+ const publishGeometry = useCallback(() => {
57
+ if (geometryScheduled.current)
58
+ return;
59
+ geometryScheduled.current = true;
60
+ queueMicrotask(() => {
61
+ geometryScheduled.current = false;
62
+ if (!mounted.current)
63
+ return;
64
+ const nextMetrics = pendingMetrics.current;
65
+ const nextOffset = offsetRef.current;
66
+ setGeometry((previous) => previous.offset === nextOffset &&
67
+ Object.keys(nextMetrics).every((key) => previous.metrics[key] ===
68
+ nextMetrics[key])
69
+ ? previous : { metrics: nextMetrics, offset: nextOffset });
70
+ });
71
+ }, []);
72
+ const publishOffset = useCallback((next) => {
73
+ offsetRef.current = next;
74
+ if (next === 0)
75
+ pausedLines.current = 0;
76
+ publishGeometry();
77
+ }, [publishGeometry]);
78
+ const isHeld = useCallback(() => latest.current.hold ||
79
+ !!latest.current.scrollbar?.controller?.isCaptured(), []);
83
80
  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
- })) {
81
+ clearResume();
82
+ const current = latest.current;
83
+ if (!current.active || !shouldArmResume({ resumeMs: current.resumeMs,
84
+ offset: offsetRef.current, newLinesWhilePaused: pausedLines.current, hold: isHeld() }))
91
85
  return;
92
- }
93
- const timer = setTimeout(() => {
86
+ resumeTimer.current = setTimeout(() => {
94
87
  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]);
105
- // Ink calculates yoga layout in `onRender`, which React runs BEFORE layout
106
- // effects, so the measurement below reflects the commit that just happened.
107
- // The children have already rendered, so `tailRowsRef` is current too.
88
+ if (latest.current.active && !isHeld())
89
+ publishOffset(0);
90
+ }, current.resumeMs);
91
+ resumeTimer.current.unref?.();
92
+ }, [clearResume, isHeld, publishOffset]);
108
93
  useLayoutEffect(() => {
109
- const viewport = clipRef.current ? measureElement(clipRef.current).height : 0;
110
- const content = innerRef.current ? measureElement(innerRef.current).height : 0;
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,
94
+ if (!scrollbar)
95
+ return;
96
+ const controller = createScrollbarController({
97
+ getGeometry: () => scrollbar.geometry,
98
+ isEnabled: () => latest.current.active && scrollbar.frameReady && scrollbar.isEnabled(),
99
+ setOffset: (next) => { publishOffset(next); armResume(); },
100
+ page: (kind) => {
101
+ publishOffset(applyScrollTimes({ offset: offsetRef.current }, kind, latest.current.metrics, 1).offset);
102
+ },
103
+ onStart: () => { clearResume(); scrollbar.onStart?.(); },
104
+ onHold: (held) => { setDragging(held); armResume(); },
130
105
  });
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 });
106
+ scrollbar.controller = controller;
107
+ return () => {
108
+ controller.dispose();
109
+ scrollbar.controller = null;
110
+ scrollbar.geometry = null;
111
+ scrollbar.invalidate();
112
+ };
113
+ }, [scrollbar, publishOffset, armResume, clearResume]);
114
+ useEffect(() => clearResume, [clearResume]);
115
+ useLayoutEffect(() => {
116
+ mounted.current = true;
117
+ return () => { mounted.current = false; };
118
+ }, []);
119
+ useEffect(() => { armResume(); }, [hold, resumeMs, armResume]);
120
+ useLayoutEffect(() => {
121
+ const reset = lastReset.current !== props.resetKey;
122
+ lastReset.current = props.resetKey;
123
+ const pin = lastPin.current !== props.pinToBottomNonce;
124
+ lastPin.current = props.pinToBottomNonce;
125
+ const intent = lastIntent.current !== props.intent?.nonce ? props.intent : undefined;
126
+ lastIntent.current = props.intent?.nonce;
127
+ if (reset) {
128
+ baseline.current = null;
129
+ pausedLines.current = 0;
130
+ publishOffset(0);
131
+ scrollbar?.controller?.cancel();
139
132
  }
140
- armResume();
141
- });
142
- // I-9: seeded from the INCOMING nonce during the first render, so the effect
143
- // below cannot apply an intent on the mount that first observes it. A
144
- // `useEffect([nonce])` fires on mount as well as on change, and this
145
- // component is unmounted whenever an overlay opens (`App.tsx` swaps it out) —
146
- // without the guard, closing an overlay replays whatever wheel gesture was in
147
- // flight when it opened, and the transcript jumps a page on its own. That is
148
- // R-P1-7, which this codebase has already paid for once through the keyboard
149
- // path; the overlay-swallowing rule in `App`'s key handler becomes a SECOND
150
- // line of defence rather than the only one.
151
- const lastAppliedNonce = useRef(intent?.nonce);
152
- const intentNonce = intent?.nonce ?? 0;
153
- useEffect(() => {
154
- const current = intentRef.current;
155
- if (!current)
156
- return;
157
- if (current.nonce === lastAppliedNonce.current)
133
+ if (!active) {
134
+ clearResume();
135
+ scrollbar?.invalidate();
136
+ if (scrollbar)
137
+ scrollbar.geometry = null;
138
+ previousActive.current = false;
158
139
  return;
159
- lastAppliedNonce.current = current.nonce;
160
- const repeat = current.repeat ?? 1;
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();
165
- // Keyed on the nonce alone: `intent` is a fresh object on every parent
166
- // render, so depending on it directly would re-scroll on every keystroke.
167
- // eslint-disable-next-line react-hooks/exhaustive-deps
168
- }, [intentNonce]);
169
- useEffect(() => {
170
- setScroll({ offset: 0 });
171
- }, [pinToBottomNonce]);
172
- useEffect(() => {
173
- onScrolledLinesChange?.(offset);
174
- }, [offset, onScrolledLinesChange]);
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]);
194
- // Suppressed below `MIN_INDICATOR_COLS`, where a column of content is worth
195
- // more than the affordance. The routing never reads this flag, which is what
196
- // keeps the indicator independently revertible (§13-Q3).
197
- const indicatorVisible = showScrollIndicator && cols >= MIN_INDICATOR_COLS;
198
- // PUBLISHED, NOT LIFTED (tui-render-performance L3 / §3.3.2). The three
199
- // numbers virtualisation needs are already owned here; a `useMemo`d context
200
- // value hands them down without adding a second owner or a second measure
201
- // pass, which is exactly what this component's header (P1-7) forbids.
202
- const geometry = useMemo(() => ({ viewportRows: metrics.viewport, offset, contentRows: metrics.content }), [metrics.viewport, metrics.content, offset]);
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 }))] }) }));
140
+ }
141
+ const viewport = clipRef.current ? measureElement(clipRef.current).height : 0;
142
+ const body = bodyRef.current ? measureElement(bodyRef.current).height : 0;
143
+ const footer = footerRef.current ? measureElement(footerRef.current).height : 0;
144
+ const layout = buildDocumentLayout({ rows: viewport + 4, bodyRows: body, footerRows: footer });
145
+ const padding = props.footer === undefined ? 0 : layout.paddingRows;
146
+ const content = body + footer + padding;
147
+ const tail = props.tailRowsRef?.current.rows ?? 0;
148
+ const old = baseline.current;
149
+ const reflow = !old || old.cols !== cols || old.viewport !== viewport;
150
+ const tailDelta = reflow ? 0 : tail - old.tail;
151
+ const footerDelta = reflow ? 0 : footer - old.footer;
152
+ const paddingDelta = !old || reflow || props.footer === undefined ? 0 :
153
+ Math.max(0, viewport - old.body - old.footer - tailDelta - footerDelta) - old.padding;
154
+ const nextMetrics = { viewport, content, padding, footer };
155
+ latest.current.metrics = nextMetrics;
156
+ // Markdown estimates may take more than 50 measurements to converge in a
157
+ // tall viewport. Publishing during the layout commit recursively mounts
158
+ // another window before the height store's microtask can finish. Coalesce
159
+ // geometry with that store outside the commit; keep measuring every frame.
160
+ // Offset clamps must join the same publication: at the top, each corrected
161
+ // estimate shrinks the overflow and would otherwise recurse via setOffset.
162
+ pendingMetrics.current = nextMetrics;
163
+ publishGeometry();
164
+ const followed = reduceFollow({ offset: offsetRef.current,
165
+ overflowLines: Math.max(0, content - viewport), tailDelta,
166
+ layoutTailDelta: footerDelta + paddingDelta, hold: isHeld(),
167
+ newLinesWhilePaused: pausedLines.current });
168
+ pausedLines.current = followed.newLinesWhilePaused;
169
+ let next = pin || reset ? 0 : followed.offset;
170
+ if (!pin && intent)
171
+ next = applyScrollTimes({ offset: next }, intent.kind, nextMetrics, intent.repeat ?? 1).offset;
172
+ publishOffset(next);
173
+ baseline.current = { viewport, content, body, footer, padding, cols, tail };
174
+ if (tailDelta !== 0 || pin || intent || !previousActive.current)
175
+ armResume();
176
+ previousActive.current = true;
177
+ const bounds = `${cols}:${stdout?.columns}:${stdout?.rows}:${viewport}`;
178
+ if (bounds !== geometryBounds.current) {
179
+ geometryBounds.current = bounds;
180
+ geometryRevision.current++;
181
+ scrollbar?.invalidate();
182
+ }
183
+ if (scrollbar) {
184
+ scrollbar.geometry = { trackTop: 2, trackCol: stdout?.columns ?? cols,
185
+ trackRows: viewport, contentRows: content, offset: next,
186
+ thumb: thumbRange(viewport, content, next, true), revision: geometryRevision.current };
187
+ scrollbar.onGeometry?.();
188
+ }
189
+ const visible = composerVisible(props.composerRef?.current ?? null, clipRef.current);
190
+ if (lastVisible.current !== visible) {
191
+ lastVisible.current = visible;
192
+ props.onComposerVisibilityChange?.(visible);
193
+ }
194
+ });
195
+ const shiftUp = Math.max(0, metrics.content - metrics.viewport - offset);
196
+ useEffect(() => { props.onScrolledLinesChange?.(offset); }, [offset, props.onScrolledLinesChange]);
197
+ useEffect(() => { if (active)
198
+ props.onViewportShiftChange?.(shiftUp); }, [active, shiftUp, props.onViewportShiftChange]);
199
+ const geometry = useMemo(() => ({ viewportRows: metrics.viewport, offset,
200
+ contentRows: metrics.content, trailingContentRows: metrics.padding + metrics.footer }), [metrics, offset]);
201
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsxs(Box, { display: active ? 'flex' : 'none', flexDirection: "row", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Box, { ref: clipRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, marginTop: -shiftUp, children: [_jsx(ViewportGeometryContext.Provider, { value: geometry, children: _jsx(Box, { ref: bodyRef, flexDirection: "column", flexShrink: 0, children: props.children }) }), _jsx(Box, { height: metrics.padding, flexShrink: 0 }), _jsx(Box, { ref: footerRef, flexDirection: "column", flexShrink: 0, children: props.footer })] }) }), props.rail, props.showScrollIndicator && _jsx(ScrollIndicator, { rows: metrics.viewport, thumb: thumbRange(metrics.viewport, metrics.content, offset, true), dragging: dragging, theme: props.theme, caps: props.caps })] }), _jsx(Box, { display: active ? 'none' : 'flex', flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: props.overlay ?? null })] }));
204
202
  }