@aragon-agent/cli 0.5.1 → 0.5.2
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.
- package/CHANGELOG.md +27 -0
- package/README.md +27 -4
- package/dist/cli.js +65 -4
- package/dist/config/env.d.ts +2 -2
- package/dist/config/env.js +10 -2
- package/dist/config/load.d.ts +2 -0
- package/dist/config/load.js +23 -0
- package/dist/config/schema.d.ts +20 -0
- package/dist/config/schema.js +2 -0
- package/dist/input/mouse-events.d.ts +61 -0
- package/dist/input/mouse-events.js +153 -0
- package/dist/input/stdin-mouse-filter.d.ts +42 -0
- package/dist/input/stdin-mouse-filter.js +153 -0
- package/dist/ui/App.d.ts +9 -1
- package/dist/ui/App.js +45 -10
- package/dist/ui/Composer.d.ts +6 -1
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/PromptInput.d.ts +15 -1
- package/dist/ui/PromptInput.js +38 -1
- package/dist/ui/glyphs.d.ts +12 -0
- package/dist/ui/glyphs.js +4 -0
- package/dist/ui/layout/AppShell.d.ts +10 -1
- package/dist/ui/layout/AppShell.js +38 -3
- package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
- package/dist/ui/layout/ScrollIndicator.js +15 -0
- package/dist/ui/layout/ScrollViewport.d.ts +8 -2
- package/dist/ui/layout/ScrollViewport.js +24 -4
- package/dist/ui/layout/regions.d.ts +55 -0
- package/dist/ui/layout/regions.js +52 -0
- package/dist/ui/layout/scroll-indicator.d.ts +27 -0
- package/dist/ui/layout/scroll-indicator.js +35 -0
- package/dist/ui/layout/scroll.d.ts +9 -0
- package/dist/ui/layout/scroll.js +23 -0
- package/dist/ui/overlays/HelpOverlay.js +2 -0
- package/dist/ui/screen.d.ts +14 -1
- package/dist/ui/screen.js +37 -3
- package/dist/ui/use-startup-notices.d.ts +11 -1
- package/dist/ui/use-startup-notices.js +28 -2
- package/dist/ui/use-wheel-routing.d.ts +68 -0
- package/dist/ui/use-wheel-routing.js +139 -0
- package/package.json +2 -2
|
@@ -16,10 +16,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
16
16
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
17
17
|
import { Box, Text, measureElement, useStdout } from 'ink';
|
|
18
18
|
import { pickGlyphs } from '../glyphs.js';
|
|
19
|
-
import {
|
|
19
|
+
import { applyScrollTimes, clampScroll, } from './scroll.js';
|
|
20
|
+
import { thumbRange } from './scroll-indicator.js';
|
|
21
|
+
import { MIN_INDICATOR_COLS, ScrollIndicator } from './ScrollIndicator.js';
|
|
20
22
|
/** Below this the full hint sentence wraps and eats a second viewport row. */
|
|
21
23
|
const TERSE_HINT_COLS = 42;
|
|
22
|
-
export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesChange, theme, caps, children, }) {
|
|
24
|
+
export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesChange, showScrollIndicator = false, theme, caps, children, }) {
|
|
23
25
|
const { stdout } = useStdout();
|
|
24
26
|
const cols = stdout?.columns ?? 80;
|
|
25
27
|
const glyphs = pickGlyphs(caps);
|
|
@@ -38,12 +40,26 @@ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesCh
|
|
|
38
40
|
const content = innerRef.current ? measureElement(innerRef.current).height : 0;
|
|
39
41
|
setMetrics((prev) => (prev.viewport === viewport && prev.content === content ? prev : { viewport, content }));
|
|
40
42
|
});
|
|
43
|
+
// I-9: seeded from the INCOMING nonce during the first render, so the effect
|
|
44
|
+
// below cannot apply an intent on the mount that first observes it. A
|
|
45
|
+
// `useEffect([nonce])` fires on mount as well as on change, and this
|
|
46
|
+
// component is unmounted whenever an overlay opens (`App.tsx` swaps it out) —
|
|
47
|
+
// without the guard, closing an overlay replays whatever wheel gesture was in
|
|
48
|
+
// flight when it opened, and the transcript jumps a page on its own. That is
|
|
49
|
+
// R-P1-7, which this codebase has already paid for once through the keyboard
|
|
50
|
+
// path; the overlay-swallowing rule in `App`'s key handler becomes a SECOND
|
|
51
|
+
// line of defence rather than the only one.
|
|
52
|
+
const lastAppliedNonce = useRef(intent?.nonce);
|
|
41
53
|
const intentNonce = intent?.nonce ?? 0;
|
|
42
54
|
useEffect(() => {
|
|
43
55
|
const current = intentRef.current;
|
|
44
56
|
if (!current)
|
|
45
57
|
return;
|
|
46
|
-
|
|
58
|
+
if (current.nonce === lastAppliedNonce.current)
|
|
59
|
+
return;
|
|
60
|
+
lastAppliedNonce.current = current.nonce;
|
|
61
|
+
const repeat = current.repeat ?? 1;
|
|
62
|
+
setScroll((prev) => applyScrollTimes(prev, current.kind, metricsRef.current, repeat));
|
|
47
63
|
// Keyed on the nonce alone: `intent` is a fresh object on every parent
|
|
48
64
|
// render, so depending on it directly would re-scroll on every keystroke.
|
|
49
65
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -61,7 +77,11 @@ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesCh
|
|
|
61
77
|
// what "N new lines you have not scrolled down to" means.
|
|
62
78
|
const hiddenBelow = offset;
|
|
63
79
|
const showHint = hiddenBelow > 0;
|
|
64
|
-
|
|
80
|
+
// Suppressed below `MIN_INDICATOR_COLS`, where a column of content is worth
|
|
81
|
+
// more than the affordance. The routing never reads this flag, which is what
|
|
82
|
+
// keeps the indicator independently revertible (§13-Q3).
|
|
83
|
+
const indicatorVisible = showScrollIndicator && cols >= MIN_INDICATOR_COLS;
|
|
84
|
+
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: children }) }), indicatorVisible && (_jsx(ScrollIndicator, { rows: metrics.viewport, thumb: thumbRange(metrics.viewport, metrics.content, offset), theme: theme, caps: caps }))] }), showHint && (
|
|
65
85
|
// Not an overlay — Ink has no absolute positioning or z-index. This takes
|
|
66
86
|
// the viewport's last row, trading one line of content for a hint that
|
|
67
87
|
// cannot drift. Cheaper than stacking with negative margins.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wheel band hit-testing (mouse-wheel-region-routing §4.5) — pure, React-free.
|
|
3
|
+
*
|
|
4
|
+
* The requirement is stated in terms of REGIONS ("scrolling over the agent area
|
|
5
|
+
* scrolls the transcript; scrolling over the input box steps prompt history"),
|
|
6
|
+
* so the router needs to turn a reported terminal row into one of two bands.
|
|
7
|
+
*
|
|
8
|
+
* The boundary is MEASURED, never computed from `budget.ts`. That arithmetic
|
|
9
|
+
* describes the PLANNED chrome, but the real bottom chrome grows and shrinks at
|
|
10
|
+
* run time: the toast row comes and goes, the hint row is dropped below
|
|
11
|
+
* `HINT_MIN_ROWS`, and the autocomplete popup adds up to nine rows. Routing off
|
|
12
|
+
* the plan would put the boundary in the wrong place exactly when the popup is
|
|
13
|
+
* open — the moment precision matters most (D-5 / D-16).
|
|
14
|
+
*/
|
|
15
|
+
export type WheelBand = 'content' | 'composer';
|
|
16
|
+
export interface FrameGeometry {
|
|
17
|
+
/**
|
|
18
|
+
* 1-based row of the frame's first row. 1 in the alternate screen.
|
|
19
|
+
*
|
|
20
|
+
* This is DERIVED, not assumed, and it has a named dependency: it holds
|
|
21
|
+
* because the full-screen transcript renders no `<Static>`
|
|
22
|
+
* (`Transcript.tsx`), so Ink's `log-update` writes `frameHeight(rows) = rows-1`
|
|
23
|
+
* lines plus a newline into a `rows`-row buffer — nothing scrolls, and the
|
|
24
|
+
* frame sits at rows `1 … rows-1` with the cursor parked on the undrawn last
|
|
25
|
+
* row. Reintroducing `<Static>` into the full-screen path pushes the frame
|
|
26
|
+
* down the screen and silently biases every hit test; whoever does that has
|
|
27
|
+
* to change `top` here at the same time.
|
|
28
|
+
*/
|
|
29
|
+
top: number;
|
|
30
|
+
/** Rows the frame occupies — `frameHeight(rows)`. */
|
|
31
|
+
height: number;
|
|
32
|
+
/** Measured rows of the bottom chrome (toast + composer + status). */
|
|
33
|
+
bottom: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The "I do not know where the frame is" value.
|
|
37
|
+
*
|
|
38
|
+
* `App` writes it into the geometry ref on every render path that does NOT
|
|
39
|
+
* render the full-screen `AppShell` — the terminal-too-small placeholder and
|
|
40
|
+
* inline mode — so a stale measurement can never outlive the frame it
|
|
41
|
+
* described. There is deliberately no arithmetic fallback: an unmeasured frame
|
|
42
|
+
* routes to `'content'`, which is the band that cannot destroy anything, and
|
|
43
|
+
* one invisible frame after a resize is a better failure than a wrong band
|
|
44
|
+
* (§4.5, P1-5 / D-16).
|
|
45
|
+
*/
|
|
46
|
+
export declare const UNMEASURED_GEOMETRY: FrameGeometry;
|
|
47
|
+
/**
|
|
48
|
+
* Which band does terminal row `y` belong to?
|
|
49
|
+
*
|
|
50
|
+
* Fails toward `'content'`: the whole point of this feature is that a wheel
|
|
51
|
+
* notch must never edit the user's draft (I-3), so every degenerate input —
|
|
52
|
+
* nothing measured yet, a row outside the frame, a chrome measurement that
|
|
53
|
+
* swallowed the screen — resolves to the harmless direction.
|
|
54
|
+
*/
|
|
55
|
+
export declare function hitTestWheel(y: number, geometry: FrameGeometry): WheelBand;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wheel band hit-testing (mouse-wheel-region-routing §4.5) — pure, React-free.
|
|
3
|
+
*
|
|
4
|
+
* The requirement is stated in terms of REGIONS ("scrolling over the agent area
|
|
5
|
+
* scrolls the transcript; scrolling over the input box steps prompt history"),
|
|
6
|
+
* so the router needs to turn a reported terminal row into one of two bands.
|
|
7
|
+
*
|
|
8
|
+
* The boundary is MEASURED, never computed from `budget.ts`. That arithmetic
|
|
9
|
+
* describes the PLANNED chrome, but the real bottom chrome grows and shrinks at
|
|
10
|
+
* run time: the toast row comes and goes, the hint row is dropped below
|
|
11
|
+
* `HINT_MIN_ROWS`, and the autocomplete popup adds up to nine rows. Routing off
|
|
12
|
+
* the plan would put the boundary in the wrong place exactly when the popup is
|
|
13
|
+
* open — the moment precision matters most (D-5 / D-16).
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The "I do not know where the frame is" value.
|
|
17
|
+
*
|
|
18
|
+
* `App` writes it into the geometry ref on every render path that does NOT
|
|
19
|
+
* render the full-screen `AppShell` — the terminal-too-small placeholder and
|
|
20
|
+
* inline mode — so a stale measurement can never outlive the frame it
|
|
21
|
+
* described. There is deliberately no arithmetic fallback: an unmeasured frame
|
|
22
|
+
* routes to `'content'`, which is the band that cannot destroy anything, and
|
|
23
|
+
* one invisible frame after a resize is a better failure than a wrong band
|
|
24
|
+
* (§4.5, P1-5 / D-16).
|
|
25
|
+
*/
|
|
26
|
+
export const UNMEASURED_GEOMETRY = { top: 1, height: 0, bottom: 0 };
|
|
27
|
+
/**
|
|
28
|
+
* Which band does terminal row `y` belong to?
|
|
29
|
+
*
|
|
30
|
+
* Fails toward `'content'`: the whole point of this feature is that a wheel
|
|
31
|
+
* notch must never edit the user's draft (I-3), so every degenerate input —
|
|
32
|
+
* nothing measured yet, a row outside the frame, a chrome measurement that
|
|
33
|
+
* swallowed the screen — resolves to the harmless direction.
|
|
34
|
+
*/
|
|
35
|
+
export function hitTestWheel(y, geometry) {
|
|
36
|
+
const { top, height } = geometry;
|
|
37
|
+
if (!Number.isFinite(height) || height <= 0)
|
|
38
|
+
return 'content';
|
|
39
|
+
if (!Number.isFinite(y))
|
|
40
|
+
return 'content';
|
|
41
|
+
// Clamp into the frame so the one undrawn bottom row — and any off-by-one
|
|
42
|
+
// from a terminal that reports 0-based rows — lands on a real band instead of
|
|
43
|
+
// being dropped.
|
|
44
|
+
const row = Math.min(Math.max(Math.floor(y), top), top + height - 1);
|
|
45
|
+
// I-6: at least one content row always survives. This clamp is a backstop
|
|
46
|
+
// that should never fire, and that is checkable rather than hopeful: the
|
|
47
|
+
// completion popup is capped at `viewportRows(rows) - 4`, so the measured
|
|
48
|
+
// chrome tops out at `rows - 6` against a frame of `rows - 1` — five content
|
|
49
|
+
// rows survive the worst case the layout can produce.
|
|
50
|
+
const bottom = Math.min(Math.max(Math.floor(geometry.bottom) || 0, 0), height - 1);
|
|
51
|
+
return row >= top + height - bottom ? 'composer' : 'content';
|
|
52
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll-thumb geometry (mouse-wheel-region-routing §4.8) — pure, React-free.
|
|
3
|
+
*
|
|
4
|
+
* Wheel scrolling without a position indicator is a half-finished affordance:
|
|
5
|
+
* the user learns THAT they moved but not HOW FAR or how much is left. The
|
|
6
|
+
* status bar's `↑N` answers neither.
|
|
7
|
+
*
|
|
8
|
+
* OFFSET COUNTS FROM THE BOTTOM, exactly as `scroll.ts` documents — `0` is
|
|
9
|
+
* pinned to the newest output and `overflow` is parked at the very top. The
|
|
10
|
+
* rows hidden ABOVE are therefore `overflow - offset`, and that inversion is
|
|
11
|
+
* precisely what gets done by accident, so it is computed here once and named.
|
|
12
|
+
*/
|
|
13
|
+
export interface ThumbRange {
|
|
14
|
+
/** 0-based row of the thumb's first row, within the viewport. */
|
|
15
|
+
start: number;
|
|
16
|
+
/** Thumb height in rows; always >= 1 when a range is returned. */
|
|
17
|
+
size: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Where the thumb sits, or `null` when the content fits and there is nothing to
|
|
21
|
+
* indicate.
|
|
22
|
+
*
|
|
23
|
+
* `size` never rounds to zero: a 10-row window over a 5000-row transcript still
|
|
24
|
+
* gets a one-row thumb, because an indicator that vanishes at exactly the
|
|
25
|
+
* moment the transcript is longest is worse than no indicator at all.
|
|
26
|
+
*/
|
|
27
|
+
export declare function thumbRange(viewportRows: number, contentRows: number, offsetFromBottom: number): ThumbRange | null;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll-thumb geometry (mouse-wheel-region-routing §4.8) — pure, React-free.
|
|
3
|
+
*
|
|
4
|
+
* Wheel scrolling without a position indicator is a half-finished affordance:
|
|
5
|
+
* the user learns THAT they moved but not HOW FAR or how much is left. The
|
|
6
|
+
* status bar's `↑N` answers neither.
|
|
7
|
+
*
|
|
8
|
+
* OFFSET COUNTS FROM THE BOTTOM, exactly as `scroll.ts` documents — `0` is
|
|
9
|
+
* pinned to the newest output and `overflow` is parked at the very top. The
|
|
10
|
+
* rows hidden ABOVE are therefore `overflow - offset`, and that inversion is
|
|
11
|
+
* precisely what gets done by accident, so it is computed here once and named.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Where the thumb sits, or `null` when the content fits and there is nothing to
|
|
15
|
+
* indicate.
|
|
16
|
+
*
|
|
17
|
+
* `size` never rounds to zero: a 10-row window over a 5000-row transcript still
|
|
18
|
+
* gets a one-row thumb, because an indicator that vanishes at exactly the
|
|
19
|
+
* moment the transcript is longest is worse than no indicator at all.
|
|
20
|
+
*/
|
|
21
|
+
export function thumbRange(viewportRows, contentRows, offsetFromBottom) {
|
|
22
|
+
const viewport = Math.floor(viewportRows);
|
|
23
|
+
const content = Math.floor(contentRows);
|
|
24
|
+
if (!Number.isFinite(viewport) || !Number.isFinite(content))
|
|
25
|
+
return null;
|
|
26
|
+
if (viewport <= 0 || content <= viewport)
|
|
27
|
+
return null;
|
|
28
|
+
const overflow = content - viewport;
|
|
29
|
+
const offset = Math.min(Math.max(Math.floor(offsetFromBottom) || 0, 0), overflow);
|
|
30
|
+
const size = Math.min(viewport, Math.max(1, Math.round((viewport * viewport) / content)));
|
|
31
|
+
const travel = viewport - size;
|
|
32
|
+
const hiddenAbove = overflow - offset;
|
|
33
|
+
const start = travel <= 0 ? 0 : Math.round((hiddenAbove / overflow) * travel);
|
|
34
|
+
return { start: Math.min(Math.max(start, 0), travel), size };
|
|
35
|
+
}
|
|
@@ -26,3 +26,12 @@ export declare function clampScroll(offset: number, overflowLines: number): numb
|
|
|
26
26
|
export declare function pageSize(viewportHeight: number): number;
|
|
27
27
|
export declare function isPinnedToBottom(state: ScrollState): boolean;
|
|
28
28
|
export declare function applyScroll(state: ScrollState, intent: ScrollIntent, metrics: ViewportMetrics): ScrollState;
|
|
29
|
+
/**
|
|
30
|
+
* Fold `applyScroll` `times` times — one `setState`, one render, however fast
|
|
31
|
+
* the wheel spins (mouse-wheel-region-routing §5.5).
|
|
32
|
+
*
|
|
33
|
+
* Folding rather than multiplying is deliberate: `applyScroll` owns the
|
|
34
|
+
* downward bottom-snap and the clamp, and reimplementing either here would give
|
|
35
|
+
* the wheel a different notion of "pinned to the bottom" than the keyboard has.
|
|
36
|
+
*/
|
|
37
|
+
export declare function applyScrollTimes(state: ScrollState, intent: ScrollIntent, metrics: ViewportMetrics, times: number): ScrollState;
|
package/dist/ui/layout/scroll.js
CHANGED
|
@@ -74,3 +74,26 @@ export function applyScroll(state, intent, metrics) {
|
|
|
74
74
|
}
|
|
75
75
|
return { offset: clamped };
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Upper bound on one folded burst. `repeat` arrives from the wheel router's
|
|
79
|
+
* coalescing window, and an unbounded fold is a free CPU-burn primitive for a
|
|
80
|
+
* high-resolution / free-spinning wheel (R-10).
|
|
81
|
+
*/
|
|
82
|
+
const MAX_SCROLL_REPEAT = 100;
|
|
83
|
+
/**
|
|
84
|
+
* Fold `applyScroll` `times` times — one `setState`, one render, however fast
|
|
85
|
+
* the wheel spins (mouse-wheel-region-routing §5.5).
|
|
86
|
+
*
|
|
87
|
+
* Folding rather than multiplying is deliberate: `applyScroll` owns the
|
|
88
|
+
* downward bottom-snap and the clamp, and reimplementing either here would give
|
|
89
|
+
* the wheel a different notion of "pinned to the bottom" than the keyboard has.
|
|
90
|
+
*/
|
|
91
|
+
export function applyScrollTimes(state, intent, metrics, times) {
|
|
92
|
+
const n = Number.isFinite(times)
|
|
93
|
+
? Math.min(MAX_SCROLL_REPEAT, Math.max(1, Math.floor(times)))
|
|
94
|
+
: 1;
|
|
95
|
+
let next = state;
|
|
96
|
+
for (let i = 0; i < n; i += 1)
|
|
97
|
+
next = applyScroll(next, intent, metrics);
|
|
98
|
+
return next;
|
|
99
|
+
}
|
|
@@ -13,6 +13,8 @@ function keyRows(times) {
|
|
|
13
13
|
['Ctrl+T', 'Toggle thinking blocks'],
|
|
14
14
|
['PgUp / PgDn', 'Scroll a page (transcript, or this overlay)'],
|
|
15
15
|
['Shift+Up / Shift+Down', 'Scroll the transcript a line (full-screen mode)'],
|
|
16
|
+
['Wheel', 'Scroll the transcript (over the input box: prompt history)'],
|
|
17
|
+
['Shift+Wheel', 'Scroll the transcript a page'],
|
|
16
18
|
['Up / Down', 'Prompt history (empty input)'],
|
|
17
19
|
];
|
|
18
20
|
}
|
package/dist/ui/screen.d.ts
CHANGED
|
@@ -15,11 +15,24 @@ export interface ScreenHandle {
|
|
|
15
15
|
/** Leave the alternate screen. Idempotent — safe from every exit path. */
|
|
16
16
|
restore(): void;
|
|
17
17
|
}
|
|
18
|
+
export interface AltScreenOptions {
|
|
19
|
+
/**
|
|
20
|
+
* TRUE MEANS "A MOUSE FILTER IS ALREADY INSTALLED", NOT "THE USER WANTS MOUSE
|
|
21
|
+
* SUPPORT" (invariant I-8).
|
|
22
|
+
*
|
|
23
|
+
* This module must never derive the value from config, env or platform.
|
|
24
|
+
* Enabling reporting without a filter in front of Ink does not degrade the
|
|
25
|
+
* feature — it types `[<0;12;5M` into the user's message on every click and
|
|
26
|
+
* every notch, which is strictly worse than the bug the feature fixes. The
|
|
27
|
+
* only caller is `cli.tsx::runInteractive`, and it passes `filter !== null`.
|
|
28
|
+
*/
|
|
29
|
+
readonly mouse: boolean;
|
|
30
|
+
}
|
|
18
31
|
/**
|
|
19
32
|
* Enter the alternate screen. A non-TTY stdout is a hard no-op (tests, pipes,
|
|
20
33
|
* CI) — writing screen-control sequences into a redirected file corrupts it.
|
|
21
34
|
*/
|
|
22
|
-
export declare function enterAltScreen(stdout: NodeJS.WriteStream | undefined): ScreenHandle;
|
|
35
|
+
export declare function enterAltScreen(stdout: NodeJS.WriteStream | undefined, options?: AltScreenOptions): ScreenHandle;
|
|
23
36
|
/**
|
|
24
37
|
* Replay the session summary into the normal buffer after `restore()`, so the
|
|
25
38
|
* conversation does not evaporate along with the alternate screen.
|
package/dist/ui/screen.js
CHANGED
|
@@ -17,6 +17,32 @@ const CURSOR_HOME = '\x1b[H';
|
|
|
17
17
|
const CLEAR_VISIBLE = '\x1b[2J';
|
|
18
18
|
/** Ink's log-update hides the cursor; a signal kill never gives it back. */
|
|
19
19
|
const SHOW_CURSOR = '\x1b[?25h';
|
|
20
|
+
/**
|
|
21
|
+
* `?1000` is NORMAL TRACKING (press / release), the minimum mode that reports
|
|
22
|
+
* wheel buttons; `?1006` selects SGR encoding so coordinates arrive as decimal
|
|
23
|
+
* text rather than raw bytes above 0x7F that `setEncoding('utf8')` would
|
|
24
|
+
* mangle. Motion tracking (`?1002` / `?1003`) is deliberately NOT enabled: it
|
|
25
|
+
* floods stdin on every pointer move and buys nothing this feature uses.
|
|
26
|
+
*/
|
|
27
|
+
const ENABLE_MOUSE = '\x1b[?1000h\x1b[?1006h';
|
|
28
|
+
const DISABLE_MOUSE = '\x1b[?1006l\x1b[?1000l';
|
|
29
|
+
/**
|
|
30
|
+
* Alternate scroll (DEC private mode 1007) is what turns a wheel notch into a
|
|
31
|
+
* burst of arrow keys, which `PromptInput` reads as prompt-history recall. With
|
|
32
|
+
* mouse reporting ON we do not touch it at all: 1007 is defined to translate
|
|
33
|
+
* the wheel only while no application is tracking the mouse, so `?1000h`
|
|
34
|
+
* already suppresses it and a second mutation of shared state buys nothing.
|
|
35
|
+
*
|
|
36
|
+
* With mouse reporting OFF we turn it off so the wheel is INERT rather than
|
|
37
|
+
* destructive — the fallback rung of the fail-safe ladder. But 1007 is GLOBAL
|
|
38
|
+
* TERMINAL STATE THIS APP DOES NOT OWN, so it is SAVED and RESTORED (XTSAVE /
|
|
39
|
+
* XTRESTORE) rather than force-set on the way out: a user who deliberately
|
|
40
|
+
* disabled alternate scroll in their terminal config must not have it switched
|
|
41
|
+
* back on by running `aragon --no-mouse` once. A terminal implementing neither
|
|
42
|
+
* sequence ignores both, so the fallback is no worse than writing nothing.
|
|
43
|
+
*/
|
|
44
|
+
const SAVE_ALT_SCROLL_OFF = '\x1b[?1007s\x1b[?1007l';
|
|
45
|
+
const RESTORE_ALT_SCROLL = '\x1b[?1007r';
|
|
20
46
|
const NOOP_HANDLE = { restore() { } };
|
|
21
47
|
function safeWrite(stdout, data) {
|
|
22
48
|
try {
|
|
@@ -30,17 +56,25 @@ function safeWrite(stdout, data) {
|
|
|
30
56
|
* Enter the alternate screen. A non-TTY stdout is a hard no-op (tests, pipes,
|
|
31
57
|
* CI) — writing screen-control sequences into a redirected file corrupts it.
|
|
32
58
|
*/
|
|
33
|
-
export function enterAltScreen(stdout) {
|
|
59
|
+
export function enterAltScreen(stdout, options = { mouse: false }) {
|
|
34
60
|
if (!stdout || !stdout.isTTY || typeof stdout.write !== 'function')
|
|
35
61
|
return NOOP_HANDLE;
|
|
36
|
-
|
|
62
|
+
const mouse = options.mouse === true;
|
|
63
|
+
safeWrite(stdout, ENTER_ALT + CLEAR_VISIBLE + CURSOR_HOME + (mouse ? ENABLE_MOUSE : SAVE_ALT_SCROLL_OFF));
|
|
37
64
|
let restored = false;
|
|
38
65
|
return {
|
|
39
66
|
restore() {
|
|
40
67
|
if (restored)
|
|
41
68
|
return;
|
|
42
69
|
restored = true;
|
|
43
|
-
|
|
70
|
+
// Mouse reporting is disabled BEFORE leaving the alternate screen (I-2).
|
|
71
|
+
// This handle is what all four exit paths converge on — `process.on
|
|
72
|
+
// ('exit')`, the crash handler's `setScreenRestore`, the signal
|
|
73
|
+
// terminator, and `waitUntilExit()` — which is why the sequences live
|
|
74
|
+
// here and not in `App.tsx`, a component that is on none of them.
|
|
75
|
+
// Leaving reporting on prints `[<0;12;5M` in the user's shell on every
|
|
76
|
+
// click, forever, until they run `reset`.
|
|
77
|
+
safeWrite(stdout, (mouse ? DISABLE_MOUSE : RESTORE_ALT_SCROLL) + SHOW_CURSOR + LEAVE_ALT);
|
|
44
78
|
},
|
|
45
79
|
};
|
|
46
80
|
}
|
|
@@ -12,4 +12,14 @@
|
|
|
12
12
|
* not otherwise see — rather than belonging to the view.
|
|
13
13
|
*/
|
|
14
14
|
import type { ViewAction } from '../agent/reducer.js';
|
|
15
|
-
export
|
|
15
|
+
export interface MouseNoticeOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Whether reporting is ACTUALLY in effect — i.e. a filter was installed, not
|
|
18
|
+
* merely that the config asked for one. A user on a terminal where R-1 fires
|
|
19
|
+
* must never be given advice about a mode that is not running.
|
|
20
|
+
*/
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
/** Persist `mouseNoticeSeen`. Called once, after the notice is dispatched. */
|
|
23
|
+
onSeen: () => void;
|
|
24
|
+
}
|
|
25
|
+
export declare function useStartupNotices(dispatch: (action: ViewAction) => void, mouseNotice?: MouseNoticeOptions): void;
|
|
@@ -11,10 +11,20 @@
|
|
|
11
11
|
* because these two effects share one job — surfacing a condition the user could
|
|
12
12
|
* not otherwise see — rather than belonging to the view.
|
|
13
13
|
*/
|
|
14
|
-
import { useEffect } from 'react';
|
|
14
|
+
import { useEffect, useRef } from 'react';
|
|
15
15
|
import { readConfigFile } from '../config/store.js';
|
|
16
16
|
import { getLogger } from '../logging/logger.js';
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The §6.1 notice (mouse-wheel-region-routing). Shown at most once, ever.
|
|
19
|
+
*
|
|
20
|
+
* R-3 is the only part of wheel region routing that reaches users who never
|
|
21
|
+
* scroll, and it REMOVES a gesture — drag to select — that is used more often
|
|
22
|
+
* than the one being added. A README is consulted only after the belief
|
|
23
|
+
* "aragon broke copy/paste" has already formed.
|
|
24
|
+
*/
|
|
25
|
+
const MOUSE_SELECTION_NOTICE = 'Mouse wheel scrolls the transcript. Hold Shift to select text, ' +
|
|
26
|
+
'or turn it off with `aragon config set mouse false`.';
|
|
27
|
+
export function useStartupNotices(dispatch, mouseNotice) {
|
|
18
28
|
// The sink gives up after repeated write failures (a read-only log directory,
|
|
19
29
|
// a full disk). Silently logging nothing is exactly the state a user must not
|
|
20
30
|
// discover later, from an empty file, while trying to explain a bug.
|
|
@@ -35,4 +45,20 @@ export function useStartupNotices(dispatch) {
|
|
|
35
45
|
text: `Using default settings: could not parse ${parseError}`,
|
|
36
46
|
});
|
|
37
47
|
}, [dispatch]);
|
|
48
|
+
// A `notice`, not a toast: it lands in the transcript and scrolls away
|
|
49
|
+
// instead of stealing a row. §6's argument against toasts is about per-notch
|
|
50
|
+
// chatter and does not extend to a one-shot.
|
|
51
|
+
const noticeRef = useRef(mouseNotice);
|
|
52
|
+
noticeRef.current = mouseNotice;
|
|
53
|
+
const mouseEnabled = mouseNotice?.enabled ?? false;
|
|
54
|
+
const mouseNoticeShown = useRef(false);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!mouseEnabled || mouseNoticeShown.current)
|
|
57
|
+
return;
|
|
58
|
+
if (readConfigFile().config?.mouseNoticeSeen)
|
|
59
|
+
return;
|
|
60
|
+
mouseNoticeShown.current = true;
|
|
61
|
+
dispatch({ type: 'notice', level: 'info', text: MOUSE_SELECTION_NOTICE });
|
|
62
|
+
noticeRef.current?.onSeen();
|
|
63
|
+
}, [mouseEnabled, dispatch]);
|
|
38
64
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wheel routing (mouse-wheel-region-routing §4.6 / §4.7).
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to the mouse source, turns each wheel notch into a band via
|
|
5
|
+
* `hitTestWheel`, and dispatches it to the transcript, an open overlay, or the
|
|
6
|
+
* prompt-history stepper. Lives outside `App.tsx` because that file is exactly
|
|
7
|
+
* at the 1000-line ceiling `CLAUDE.md` sets, and because the routing table is
|
|
8
|
+
* the most testable part of this feature and does not want to be inside a
|
|
9
|
+
* 1000-line component (D-13).
|
|
10
|
+
*
|
|
11
|
+
* THE TWO BANDS HAVE DIFFERENT PHYSICS, and that is the requirement rather than
|
|
12
|
+
* a tuning choice (§4.7). A physical notch produces ONE SGR report, whereas
|
|
13
|
+
* alternate-scroll used to produce three arrow keys:
|
|
14
|
+
*
|
|
15
|
+
* - transcript: 3 rows per notch, matching the terminal's own convention and
|
|
16
|
+
* the muscle memory users bring from every other full-screen app;
|
|
17
|
+
* - composer: exactly ONE history step per notch. Multiplying here would make
|
|
18
|
+
* a single flick jump three prompts back — unusable, and destructive when a
|
|
19
|
+
* draft is in play.
|
|
20
|
+
*
|
|
21
|
+
* Bursts are handled differently for the same reason: transcript events are
|
|
22
|
+
* coalesced into one intent carrying `repeat`, composer events never are (they
|
|
23
|
+
* are rate-limited by the human, and each must produce its own visible step).
|
|
24
|
+
*/
|
|
25
|
+
import { type MutableRefObject } from 'react';
|
|
26
|
+
import type { Overlay } from '../agent/reducer.js';
|
|
27
|
+
import type { MouseSource } from '../input/stdin-mouse-filter.js';
|
|
28
|
+
import { type FrameGeometry } from './layout/regions.js';
|
|
29
|
+
import type { ScrollIntent } from './layout/scroll.js';
|
|
30
|
+
/**
|
|
31
|
+
* Rows a PgUp/PgDn — and a Shift+wheel notch — moves inside a controlled
|
|
32
|
+
* overlay. Declared here rather than in `App.tsx` because both the keyboard
|
|
33
|
+
* branch and the wheel branch need it and `App` imports this module; the other
|
|
34
|
+
* direction would be a cycle.
|
|
35
|
+
*/
|
|
36
|
+
export declare const OVERLAY_PAGE = 8;
|
|
37
|
+
export interface WheelRoutingOptions {
|
|
38
|
+
/** Absent when mouse support is off, in inline mode, or on a non-TTY. */
|
|
39
|
+
mouseSource?: MouseSource;
|
|
40
|
+
/** Live frame geometry, written by `AppShell`'s layout effect (§4.5). */
|
|
41
|
+
geometryRef: MutableRefObject<FrameGeometry>;
|
|
42
|
+
/**
|
|
43
|
+
* Test-only seam (§8.2): when set, the router reads this instead of the
|
|
44
|
+
* measured ref, so a routing test never depends on `measureElement` working
|
|
45
|
+
* under `ink-testing-library` — a dependency no other test in this package
|
|
46
|
+
* exercises, and one that would make a routing regression present as a
|
|
47
|
+
* measurement flake.
|
|
48
|
+
*/
|
|
49
|
+
geometryOverride?: FrameGeometry;
|
|
50
|
+
/** Read at EVENT time for the routing branch, and again at FLUSH time (P1-3). */
|
|
51
|
+
getOverlay: () => Overlay | null;
|
|
52
|
+
onScroll: (kind: ScrollIntent, repeat: number) => void;
|
|
53
|
+
/** Signed row delta; the caller clamps at 0 the way the keyboard branch does. */
|
|
54
|
+
onOverlayScroll: (delta: number) => void;
|
|
55
|
+
onHistoryStep: (dir: 'up' | 'down') => void;
|
|
56
|
+
}
|
|
57
|
+
export interface WheelRouting {
|
|
58
|
+
/**
|
|
59
|
+
* Drop any accumulated-but-unflushed transcript scroll.
|
|
60
|
+
*
|
|
61
|
+
* Called by `App` from its existing `state.overlay` effect rather than from a
|
|
62
|
+
* second subscriber to the same transition. This is guard #2 of the pair that
|
|
63
|
+
* has to ship together (§14 condition 3); guard #1 is the overlay re-check
|
|
64
|
+
* inside the flush, and guard #3 is `ScrollViewport`'s I-9 mount-skip.
|
|
65
|
+
*/
|
|
66
|
+
clearCoalescer: () => void;
|
|
67
|
+
}
|
|
68
|
+
export declare function useWheelRouting(options: WheelRoutingOptions): WheelRouting;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wheel routing (mouse-wheel-region-routing §4.6 / §4.7).
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to the mouse source, turns each wheel notch into a band via
|
|
5
|
+
* `hitTestWheel`, and dispatches it to the transcript, an open overlay, or the
|
|
6
|
+
* prompt-history stepper. Lives outside `App.tsx` because that file is exactly
|
|
7
|
+
* at the 1000-line ceiling `CLAUDE.md` sets, and because the routing table is
|
|
8
|
+
* the most testable part of this feature and does not want to be inside a
|
|
9
|
+
* 1000-line component (D-13).
|
|
10
|
+
*
|
|
11
|
+
* THE TWO BANDS HAVE DIFFERENT PHYSICS, and that is the requirement rather than
|
|
12
|
+
* a tuning choice (§4.7). A physical notch produces ONE SGR report, whereas
|
|
13
|
+
* alternate-scroll used to produce three arrow keys:
|
|
14
|
+
*
|
|
15
|
+
* - transcript: 3 rows per notch, matching the terminal's own convention and
|
|
16
|
+
* the muscle memory users bring from every other full-screen app;
|
|
17
|
+
* - composer: exactly ONE history step per notch. Multiplying here would make
|
|
18
|
+
* a single flick jump three prompts back — unusable, and destructive when a
|
|
19
|
+
* draft is in play.
|
|
20
|
+
*
|
|
21
|
+
* Bursts are handled differently for the same reason: transcript events are
|
|
22
|
+
* coalesced into one intent carrying `repeat`, composer events never are (they
|
|
23
|
+
* are rate-limited by the human, and each must produce its own visible step).
|
|
24
|
+
*/
|
|
25
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
26
|
+
import { hitTestWheel } from './layout/regions.js';
|
|
27
|
+
/**
|
|
28
|
+
* Rows a PgUp/PgDn — and a Shift+wheel notch — moves inside a controlled
|
|
29
|
+
* overlay. Declared here rather than in `App.tsx` because both the keyboard
|
|
30
|
+
* branch and the wheel branch need it and `App` imports this module; the other
|
|
31
|
+
* direction would be a cycle.
|
|
32
|
+
*/
|
|
33
|
+
export const OVERLAY_PAGE = 8;
|
|
34
|
+
/** Transcript rows per notch. The terminal's own convention. */
|
|
35
|
+
const CONTENT_LINES_PER_NOTCH = 3;
|
|
36
|
+
/** Overlay rows per notch. Overlay content is short; a page needs Shift. */
|
|
37
|
+
const OVERLAY_LINES_PER_NOTCH = 3;
|
|
38
|
+
/**
|
|
39
|
+
* Coalescing window for transcript scrolling — one `setState`, one render, no
|
|
40
|
+
* matter how fast the wheel spins (R-10). Deliberately tighter than the 33 ms
|
|
41
|
+
* streaming coalescer: this one is in the direct path of a hand gesture.
|
|
42
|
+
*/
|
|
43
|
+
const WHEEL_COALESCE_MS = 16;
|
|
44
|
+
export function useWheelRouting(options) {
|
|
45
|
+
// Every mutable input reaches the subscription through this ref, so the
|
|
46
|
+
// effect below subscribes exactly once per `mouseSource` instead of tearing
|
|
47
|
+
// the listener down and rebuilding it on every parent render.
|
|
48
|
+
const optionsRef = useRef(options);
|
|
49
|
+
optionsRef.current = options;
|
|
50
|
+
const accumulator = useRef(null);
|
|
51
|
+
const timer = useRef(null);
|
|
52
|
+
const clearCoalescer = useCallback(() => {
|
|
53
|
+
if (timer.current) {
|
|
54
|
+
clearTimeout(timer.current);
|
|
55
|
+
timer.current = null;
|
|
56
|
+
}
|
|
57
|
+
accumulator.current = null;
|
|
58
|
+
}, []);
|
|
59
|
+
const flush = useCallback(() => {
|
|
60
|
+
if (timer.current) {
|
|
61
|
+
clearTimeout(timer.current);
|
|
62
|
+
timer.current = null;
|
|
63
|
+
}
|
|
64
|
+
const pending = accumulator.current;
|
|
65
|
+
accumulator.current = null;
|
|
66
|
+
if (!pending)
|
|
67
|
+
return;
|
|
68
|
+
// P1-3: THE DECISION BELONGS AT FLUSH TIME, not at event time, because the
|
|
69
|
+
// event-time decision is the one that goes stale. `App` swaps
|
|
70
|
+
// `ScrollViewport` out for an overlay, and 16 ms is more than wide enough
|
|
71
|
+
// for an agent-raised `confirm` or an `ask_user` to land inside the window
|
|
72
|
+
// — the intent would then be applied on a component that is not there, and
|
|
73
|
+
// the transcript would jump by itself when the overlay closed.
|
|
74
|
+
if (optionsRef.current.getOverlay())
|
|
75
|
+
return;
|
|
76
|
+
optionsRef.current.onScroll(pending.kind, pending.repeat);
|
|
77
|
+
}, []);
|
|
78
|
+
const accumulate = useCallback((kind, repeat) => {
|
|
79
|
+
// A fast up-then-down must never cancel itself into a no-op, so a change
|
|
80
|
+
// of direction (or of granularity) flushes what is already held.
|
|
81
|
+
if (accumulator.current && accumulator.current.kind !== kind)
|
|
82
|
+
flush();
|
|
83
|
+
const held = accumulator.current;
|
|
84
|
+
accumulator.current = { kind, repeat: (held?.repeat ?? 0) + repeat };
|
|
85
|
+
if (!timer.current) {
|
|
86
|
+
timer.current = setTimeout(flush, WHEEL_COALESCE_MS);
|
|
87
|
+
timer.current.unref?.();
|
|
88
|
+
}
|
|
89
|
+
}, [flush]);
|
|
90
|
+
const route = useCallback((event) => {
|
|
91
|
+
const current = optionsRef.current;
|
|
92
|
+
const geometry = current.geometryOverride ?? current.geometryRef.current;
|
|
93
|
+
const band = hitTestWheel(event.y, geometry);
|
|
94
|
+
const overlay = current.getOverlay();
|
|
95
|
+
// THE OVERLAY BRANCH IS EVALUATED FIRST AND RETURNS FOR EVERY BAND IT
|
|
96
|
+
// RECOGNIZES — the same two ordering rules the keyboard handler follows,
|
|
97
|
+
// and for the same reason (R-P1-7).
|
|
98
|
+
if (overlay) {
|
|
99
|
+
// The composer is inactive behind an overlay; stepping a hidden draft
|
|
100
|
+
// is worse than doing nothing.
|
|
101
|
+
if (band !== 'content')
|
|
102
|
+
return;
|
|
103
|
+
// Modes A only. `model` / `confirm` / `question` own their own keys and
|
|
104
|
+
// scrolling them would leave an inconsistent cursor (R-11).
|
|
105
|
+
const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan';
|
|
106
|
+
if (!controlled)
|
|
107
|
+
return;
|
|
108
|
+
const step = event.shift ? OVERLAY_PAGE : OVERLAY_LINES_PER_NOTCH;
|
|
109
|
+
current.onOverlayScroll(event.dir === 'up' ? -step : step);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (band === 'composer') {
|
|
113
|
+
// §4.7: the accumulator flushes when the BAND changes, not only when
|
|
114
|
+
// the direction does. The composer path is never coalesced, so without
|
|
115
|
+
// this the transcript scroll that preceded the pointer moving down
|
|
116
|
+
// lands ~16 ms AFTER the history step it happened before — the two
|
|
117
|
+
// halves of one gesture, applied out of order.
|
|
118
|
+
flush();
|
|
119
|
+
current.onHistoryStep(event.dir);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (event.shift) {
|
|
123
|
+
accumulate(event.dir === 'up' ? 'pageUp' : 'pageDown', 1);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
accumulate(event.dir === 'up' ? 'lineUp' : 'lineDown', CONTENT_LINES_PER_NOTCH);
|
|
127
|
+
}, [accumulate, flush]);
|
|
128
|
+
const { mouseSource } = options;
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!mouseSource)
|
|
131
|
+
return undefined;
|
|
132
|
+
const unsubscribe = mouseSource.subscribe(route);
|
|
133
|
+
return () => {
|
|
134
|
+
unsubscribe();
|
|
135
|
+
clearCoalescer();
|
|
136
|
+
};
|
|
137
|
+
}, [mouseSource, route, clearCoalescer]);
|
|
138
|
+
return { clearCoalescer };
|
|
139
|
+
}
|