@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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdin mouse filter (mouse-wheel-region-routing §4.2 / §5.2).
|
|
3
|
+
*
|
|
4
|
+
* WHY A WRAPPER AND NOT A SECOND LISTENER. Ink 5.2.1 owns stdin in a way that
|
|
5
|
+
* forbids a second reader: `components/App.js` calls `setEncoding('utf8')`,
|
|
6
|
+
* `ref()`, `setRawMode(true)`, subscribes to `'readable'` and drains with
|
|
7
|
+
* `read()`. Attaching our own `'data'` listener to the same `process.stdin`
|
|
8
|
+
* would switch the stream into flowing mode and RACE Ink's `read()` for chunks
|
|
9
|
+
* — some keystrokes would reach Ink, some would not, non-deterministically
|
|
10
|
+
* (D-3). So the filter REPLACES the stream Ink is given: it reads the real
|
|
11
|
+
* stdin, strips every mouse report, and writes the remainder into a
|
|
12
|
+
* `PassThrough` that carries the surface Ink requires.
|
|
13
|
+
*
|
|
14
|
+
* ORDERING IS AN INVARIANT, NOT A CONVENTION (I-8). The caller must build this
|
|
15
|
+
* filter BEFORE anything writes `\x1b[?1000h`. Reporting enabled while Ink
|
|
16
|
+
* still holds the raw stream does not degrade the feature — it types
|
|
17
|
+
* `[<0;12;5M` into the user's message on every click, which is strictly worse
|
|
18
|
+
* than the bug this feature fixes. `cli.tsx::runInteractive` is the only caller
|
|
19
|
+
* and passes `filter !== null` to `enterAltScreen`.
|
|
20
|
+
*/
|
|
21
|
+
import { PassThrough } from 'node:stream';
|
|
22
|
+
import { splitMouseEvents } from './mouse-events.js';
|
|
23
|
+
/**
|
|
24
|
+
* How long a possible-prefix tail is held before being flushed as text.
|
|
25
|
+
*
|
|
26
|
+
* This is what stops a lone `\x1b` — the Esc key, which aborts a run — from
|
|
27
|
+
* being held hostage waiting for a `[` that will never arrive. Worst-case Esc
|
|
28
|
+
* latency becomes 12 ms, and only for the keystroke that is literally a bare
|
|
29
|
+
* ESC; every other key resolves `isMousePrefix` to false immediately.
|
|
30
|
+
*/
|
|
31
|
+
const PENDING_FLUSH_MS = 12;
|
|
32
|
+
/** Assign a method onto the PassThrough without widening its public type. */
|
|
33
|
+
function define(target, name, value) {
|
|
34
|
+
Object.defineProperty(target, name, { value, writable: true, configurable: true });
|
|
35
|
+
}
|
|
36
|
+
export function createMouseFilter(real) {
|
|
37
|
+
const wrapper = new PassThrough();
|
|
38
|
+
const listeners = new Set();
|
|
39
|
+
let pending = '';
|
|
40
|
+
let flushTimer = null;
|
|
41
|
+
let disposed = false;
|
|
42
|
+
// --- The surface Ink reads off the stream it is handed. ------------------
|
|
43
|
+
// `isRawModeSupported()` reads `props.stdin.isTTY` (App.js:34), so this has
|
|
44
|
+
// to be a DELEGATING GETTER rather than a snapshot: a stdin that is not a TTY
|
|
45
|
+
// must still report so through the wrapper, or Ink throws instead of falling
|
|
46
|
+
// back.
|
|
47
|
+
Object.defineProperty(wrapper, 'isTTY', {
|
|
48
|
+
get: () => real.isTTY,
|
|
49
|
+
configurable: true,
|
|
50
|
+
});
|
|
51
|
+
define(wrapper, 'setRawMode', (mode) => {
|
|
52
|
+
real.setRawMode?.(mode);
|
|
53
|
+
return wrapper;
|
|
54
|
+
});
|
|
55
|
+
define(wrapper, 'ref', () => {
|
|
56
|
+
real.ref?.();
|
|
57
|
+
return wrapper;
|
|
58
|
+
});
|
|
59
|
+
define(wrapper, 'unref', () => {
|
|
60
|
+
real.unref?.();
|
|
61
|
+
return wrapper;
|
|
62
|
+
});
|
|
63
|
+
const emit = (event) => {
|
|
64
|
+
for (const listener of [...listeners])
|
|
65
|
+
listener(event);
|
|
66
|
+
};
|
|
67
|
+
const clearFlushTimer = () => {
|
|
68
|
+
if (!flushTimer)
|
|
69
|
+
return;
|
|
70
|
+
clearTimeout(flushTimer);
|
|
71
|
+
flushTimer = null;
|
|
72
|
+
};
|
|
73
|
+
const flushPending = () => {
|
|
74
|
+
clearFlushTimer();
|
|
75
|
+
if (pending.length === 0 || disposed)
|
|
76
|
+
return;
|
|
77
|
+
const text = pending;
|
|
78
|
+
pending = '';
|
|
79
|
+
wrapper.write(text);
|
|
80
|
+
};
|
|
81
|
+
const onData = (chunk) => {
|
|
82
|
+
if (disposed)
|
|
83
|
+
return;
|
|
84
|
+
clearFlushTimer();
|
|
85
|
+
const decoded = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
86
|
+
const split = splitMouseEvents(pending + decoded);
|
|
87
|
+
pending = split.pending;
|
|
88
|
+
if (split.text.length > 0)
|
|
89
|
+
wrapper.write(split.text);
|
|
90
|
+
for (const event of split.events)
|
|
91
|
+
emit(event);
|
|
92
|
+
if (pending.length > 0) {
|
|
93
|
+
// `.unref()` so a held tail can never keep the process alive on its own.
|
|
94
|
+
flushTimer = setTimeout(flushPending, PENDING_FLUSH_MS);
|
|
95
|
+
flushTimer.unref?.();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
// P2-7: a closed stdin must still terminate Ink's reader, or it waits forever
|
|
99
|
+
// on a stream that will never produce another byte.
|
|
100
|
+
const onEnd = () => {
|
|
101
|
+
flushPending();
|
|
102
|
+
if (!wrapper.writableEnded)
|
|
103
|
+
wrapper.end();
|
|
104
|
+
};
|
|
105
|
+
// `setEncoding('utf8')` on the REAL stream so Node's StringDecoder handles
|
|
106
|
+
// multi-byte boundaries for us — the parser works on characters, and a UTF-8
|
|
107
|
+
// sequence split across two chunks would otherwise become two mojibake
|
|
108
|
+
// characters before it ever reached `splitMouseEvents`.
|
|
109
|
+
real.setEncoding?.('utf8');
|
|
110
|
+
real.on('data', onData);
|
|
111
|
+
real.once('end', onEnd);
|
|
112
|
+
real.once('close', onEnd);
|
|
113
|
+
return {
|
|
114
|
+
stdin: wrapper,
|
|
115
|
+
source: {
|
|
116
|
+
subscribe(listener) {
|
|
117
|
+
listeners.add(listener);
|
|
118
|
+
return () => listeners.delete(listener);
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
dispose() {
|
|
122
|
+
if (disposed)
|
|
123
|
+
return;
|
|
124
|
+
disposed = true;
|
|
125
|
+
clearFlushTimer();
|
|
126
|
+
real.removeListener('data', onData);
|
|
127
|
+
real.removeListener('end', onEnd);
|
|
128
|
+
real.removeListener('close', onEnd);
|
|
129
|
+
listeners.clear();
|
|
130
|
+
// I-7: a live `'data'` listener on stdin keeps the event loop alive, and
|
|
131
|
+
// `aragon` would hang on exit instead of returning to the shell.
|
|
132
|
+
real.pause?.();
|
|
133
|
+
real.unref?.();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Build a filter, or `null` if construction throws.
|
|
139
|
+
*
|
|
140
|
+
* The `null` return is the whole point (P0-1): `enterAltScreen` derives whether
|
|
141
|
+
* to enable mouse reporting from `filter !== null`, so a construction failure
|
|
142
|
+
* degrades to "the wheel is inert" — a supported, tested rung of the fail-safe
|
|
143
|
+
* ladder — rather than leaving reporting on with no filter in front of Ink.
|
|
144
|
+
*/
|
|
145
|
+
export function tryCreateMouseFilter(real, onError) {
|
|
146
|
+
try {
|
|
147
|
+
return createMouseFilter(real);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
onError?.(err instanceof Error ? err.message : String(err));
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
package/dist/ui/App.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type { AgentController } from '../agent/controller.js';
|
|
|
9
9
|
import type { ConfirmRequest } from '../tools/index.js';
|
|
10
10
|
import { type Overlay } from '../agent/reducer.js';
|
|
11
11
|
import { type RenderMode } from './layout/frame.js';
|
|
12
|
+
import { type FrameGeometry } from './layout/regions.js';
|
|
13
|
+
import type { MouseSource } from '../input/stdin-mouse-filter.js';
|
|
12
14
|
import type { HumanInputBridge } from '../tools/human-input.js';
|
|
13
15
|
export interface ConfirmBridge {
|
|
14
16
|
handler: ((req: ConfirmRequest) => Promise<boolean>) | null;
|
|
@@ -23,5 +25,11 @@ export interface AppProps {
|
|
|
23
25
|
confirmBridge?: ConfirmBridge;
|
|
24
26
|
/** The plan-mode human channel; absent in tests that do not exercise it. */
|
|
25
27
|
humanInputBridge?: HumanInputBridge;
|
|
28
|
+
/** Wheel events, already stripped out of stdin. Absent whenever mouse support
|
|
29
|
+
* is off: inline mode, `--no-mouse`, a non-TTY, or a filter that failed to
|
|
30
|
+
* build (mouse-wheel-region-routing §4.2). */
|
|
31
|
+
mouseSource?: MouseSource;
|
|
32
|
+
/** Test-only seam (§8.2): frame geometry, bypassing `measureElement`. */
|
|
33
|
+
geometryOverride?: FrameGeometry;
|
|
26
34
|
}
|
|
27
|
-
export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, }: AppProps): React.ReactElement;
|
|
35
|
+
export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, geometryOverride, }: AppProps): React.ReactElement;
|
package/dist/ui/App.js
CHANGED
|
@@ -31,6 +31,8 @@ import { ScrollViewport } from './layout/ScrollViewport.js';
|
|
|
31
31
|
import { useTerminalSize } from './layout/useTerminalSize.js';
|
|
32
32
|
import { frameHeight, MIN_FULLSCREEN_ROWS } from './layout/frame.js';
|
|
33
33
|
import { HINT_MIN_ROWS, viewportRows as computeViewportRows } from './layout/budget.js';
|
|
34
|
+
import { UNMEASURED_GEOMETRY } from './layout/regions.js';
|
|
35
|
+
import { OVERLAY_PAGE, useWheelRouting } from './use-wheel-routing.js';
|
|
34
36
|
import { installConsoleBridge } from './console-bridge.js';
|
|
35
37
|
import { publishExitSnapshot } from './exit-snapshot.js';
|
|
36
38
|
import { OverlayFrame } from './layout/OverlayFrame.js';
|
|
@@ -46,9 +48,7 @@ import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js'
|
|
|
46
48
|
import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
|
|
47
49
|
/** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
|
|
48
50
|
const COALESCE_MS = 33;
|
|
49
|
-
|
|
50
|
-
const OVERLAY_PAGE = 8;
|
|
51
|
-
export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, }) {
|
|
51
|
+
export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, geometryOverride, }) {
|
|
52
52
|
const { exit } = useApp();
|
|
53
53
|
const { stdout } = useStdout();
|
|
54
54
|
const fullscreen = mode === 'fullscreen';
|
|
@@ -61,6 +61,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
61
61
|
const [promptHistory, setPromptHistory] = useState(() => controller.getConfig().promptHistory ?? []);
|
|
62
62
|
// Scroll plumbing: intent goes down, one derived display number comes back.
|
|
63
63
|
const [scrollIntent, setScrollIntent] = useState();
|
|
64
|
+
/** One prompt-history step per wheel notch over the composer band (§4.7). */
|
|
65
|
+
const [historyIntent, setHistoryIntent] = useState();
|
|
64
66
|
const [pinToBottomNonce, setPinToBottomNonce] = useState(0);
|
|
65
67
|
const [scrolledLines, setScrolledLines] = useState(0);
|
|
66
68
|
// I-5 (§4.13): Ctrl+L cannot repaint by writing escapes — Ink dedupes
|
|
@@ -98,6 +100,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
98
100
|
/** Every outstanding `gate.request()`. Held in a ref, never in state: it is
|
|
99
101
|
* bookkeeping for promises, and re-rendering on it would be noise. */
|
|
100
102
|
const pendingHuman = useRef(new Set());
|
|
103
|
+
/** Written by `AppShell`'s layout effect, read at wheel-event time (§4.5). */
|
|
104
|
+
const geometryRef = useRef(UNMEASURED_GEOMETRY);
|
|
101
105
|
/**
|
|
102
106
|
* THE SINGLE SETTLE PATH for a human request. Idempotent per entry, and it
|
|
103
107
|
* clears the overlay as its last act.
|
|
@@ -233,7 +237,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
233
237
|
return undefined;
|
|
234
238
|
return installConsoleBridge((level, text) => dispatch({ type: 'notice', level, text }));
|
|
235
239
|
}, [fullscreen]);
|
|
236
|
-
|
|
240
|
+
// `enabled` is `mouseSource`, not `cfg.mouse`: a user whose terminal drops
|
|
241
|
+
// the events must never be shown advice about a mode not in effect (§6.1).
|
|
242
|
+
useStartupNotices(dispatch, {
|
|
243
|
+
enabled: !!mouseSource,
|
|
244
|
+
onSeen: () => persistConfig({ mouseNoticeSeen: true }),
|
|
245
|
+
});
|
|
237
246
|
// --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
|
|
238
247
|
useEffect(() => {
|
|
239
248
|
publishExitSnapshot({
|
|
@@ -400,8 +409,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
400
409
|
controller.setSubmitCount(nextCount);
|
|
401
410
|
persistConfig({ promptHistory: next, submitCount: nextCount });
|
|
402
411
|
};
|
|
403
|
-
|
|
404
|
-
|
|
412
|
+
/** `repeat` folds a coalesced wheel burst; the keyboard always sends one. */
|
|
413
|
+
const scrollBy = useCallback((kind, repeat = 1) => {
|
|
414
|
+
setScrollIntent((prev) => ({ kind, nonce: (prev?.nonce ?? 0) + 1, repeat }));
|
|
405
415
|
}, []);
|
|
406
416
|
/**
|
|
407
417
|
* Send a message to the agent, bypassing slash parsing.
|
|
@@ -500,11 +510,30 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
500
510
|
}
|
|
501
511
|
resolveHuman({ kind: 'planDecision', decision: verdict.decision, feedback: verdict.feedback });
|
|
502
512
|
};
|
|
513
|
+
// --- Mouse wheel region routing (§4.6). Table and coalescer live in the
|
|
514
|
+
// hook (D-13); only the wiring it cannot own stays here. Inline arrows are
|
|
515
|
+
// fine — the hook re-reads its options through a ref every render. The
|
|
516
|
+
// `Math.max(0, …)` mirrors the keyboard branch below, and for the same
|
|
517
|
+
// reason: `OverlayFrame` only ever reports a clamp DOWNWARD.
|
|
518
|
+
const { clearCoalescer } = useWheelRouting({
|
|
519
|
+
mouseSource,
|
|
520
|
+
geometryRef,
|
|
521
|
+
geometryOverride,
|
|
522
|
+
getOverlay: () => stateRef.current.overlay,
|
|
523
|
+
onScroll: scrollBy,
|
|
524
|
+
onOverlayScroll: (delta) => setOverlayScroll((n) => Math.max(0, n + delta)),
|
|
525
|
+
onHistoryStep: (dir) => setHistoryIntent((prev) => ({ dir, nonce: (prev?.nonce ?? 0) + 1 })),
|
|
526
|
+
});
|
|
503
527
|
// A fresh overlay always opens at the top; carrying the previous overlay's
|
|
504
528
|
// offset over would open Help mid-list for no reason the user can see.
|
|
529
|
+
//
|
|
530
|
+
// `clearCoalescer()` rides along here rather than adding a second subscriber
|
|
531
|
+
// to the same transition (§4.7 rule 2): an accumulated-but-unflushed
|
|
532
|
+
// transcript scroll must not survive an overlay opening.
|
|
505
533
|
useEffect(() => {
|
|
506
534
|
setOverlayScroll(0);
|
|
507
|
-
|
|
535
|
+
clearCoalescer();
|
|
536
|
+
}, [state.overlay, clearCoalescer]);
|
|
508
537
|
// --- Initial overlay / prompt (after handlers are defined). ------------
|
|
509
538
|
useEffect(() => {
|
|
510
539
|
if (initialOverlay)
|
|
@@ -666,6 +695,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
666
695
|
apiKey: '',
|
|
667
696
|
logLevel: cfg.log.level,
|
|
668
697
|
}, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
|
|
698
|
+
// A MEASUREMENT MUST NEVER OUTLIVE THE FRAME IT DESCRIBED (P1-5): neither
|
|
699
|
+
// path below renders the full-screen `AppShell`, so nothing else would
|
|
700
|
+
// overwrite a stale `bottom`. See `UNMEASURED_GEOMETRY` for why there is no
|
|
701
|
+
// arithmetic fallback.
|
|
702
|
+
if (!fullscreen || rows < MIN_FULLSCREEN_ROWS)
|
|
703
|
+
geometryRef.current = UNMEASURED_GEOMETRY;
|
|
669
704
|
// A resize can drop below the usable floor. Do NOT leave the alt-screen —
|
|
670
705
|
// entering and leaving it on every drag is worse than a placeholder — and take
|
|
671
706
|
// the height from `frameHeight()` like everything else, because this is the
|
|
@@ -683,11 +718,11 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
683
718
|
// the header the moment the first message lands.
|
|
684
719
|
const opener = empty ? (_jsx(SessionOpener, { variant: fullscreen ? pickOpenerVariant(viewportBudget, cols, caps) : 'none', version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: fullscreen ? viewportBudget : Number.POSITIVE_INFINITY, theme: theme, caps: caps })) : null;
|
|
685
720
|
const density = cfg.density;
|
|
686
|
-
const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, windowSize: cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }), overlayNode] }));
|
|
721
|
+
const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, showScrollIndicator: true, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, windowSize: cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }), overlayNode] }));
|
|
687
722
|
// The completion popup lives inside the bottom chrome and can add up to 9
|
|
688
723
|
// rows; unbounded, it pushes the status bar past the frame height where
|
|
689
724
|
// `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
|
|
690
725
|
const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
|
|
691
|
-
const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, popupMaxRows: popupMaxRows, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
|
|
692
|
-
return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode }) }));
|
|
726
|
+
const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, popupMaxRows: popupMaxRows, historyIntent: historyIntent, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
|
|
727
|
+
return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, geometryRef: geometryRef, header: header, viewport: viewport, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode }) }));
|
|
693
728
|
}
|
package/dist/ui/Composer.d.ts
CHANGED
|
@@ -35,9 +35,14 @@ export interface ComposerProps {
|
|
|
35
35
|
/** Mirror of the controller's effective mode; drives the chip and the border. */
|
|
36
36
|
agentMode: AgentMode;
|
|
37
37
|
popupMaxRows?: number;
|
|
38
|
+
/** Pure pass-through to `PromptInput` (mouse-wheel-region-routing §5.6). */
|
|
39
|
+
historyIntent?: {
|
|
40
|
+
dir: 'up' | 'down';
|
|
41
|
+
nonce: number;
|
|
42
|
+
};
|
|
38
43
|
theme: Theme;
|
|
39
44
|
caps: TermCapabilities;
|
|
40
45
|
onSubmit: (text: string) => void;
|
|
41
46
|
onHelp?: () => void;
|
|
42
47
|
}
|
|
43
|
-
export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
|
|
48
|
+
export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, historyIntent, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
|
package/dist/ui/Composer.js
CHANGED
|
@@ -56,7 +56,7 @@ function hintText(running, submitCount, glyphs, mode) {
|
|
|
56
56
|
'? help',
|
|
57
57
|
].join(dot);
|
|
58
58
|
}
|
|
59
|
-
export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, theme, caps, onSubmit, onHelp, }) {
|
|
59
|
+
export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, historyIntent, theme, caps, onSubmit, onHelp, }) {
|
|
60
60
|
const [hasDraft, setHasDraft] = useState(false);
|
|
61
61
|
const onDraftChange = useCallback((next) => setHasDraft(next), []);
|
|
62
62
|
// Plan mode inserts ONE branch, above `draft` and below `running`. The
|
|
@@ -73,7 +73,7 @@ export function Composer({ isActive, running, history, commands, cwd, showHint,
|
|
|
73
73
|
: theme.idleBorder ?? theme.border;
|
|
74
74
|
const glyphs = pickGlyphs(caps);
|
|
75
75
|
const visible = showHint && hintsEnabled;
|
|
76
|
-
return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, bordered: { color: borderColor }, onDraftChange: onDraftChange }), visible && (
|
|
76
|
+
return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, bordered: { color: borderColor }, onDraftChange: onDraftChange, historyIntent: historyIntent }), visible && (
|
|
77
77
|
// The chip rides the EXISTING hint row rather than adding one, so it
|
|
78
78
|
// costs zero rows on a 24-row terminal.
|
|
79
79
|
_jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Text, { children: ' ' }), _jsx(ModeChip, { mode: agentMode, theme: theme, caps: caps }), _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: hintText(running, submitCount, glyphs, agentMode) })] }))] }));
|
package/dist/ui/PromptInput.d.ts
CHANGED
|
@@ -55,6 +55,20 @@ interface PromptInputProps {
|
|
|
55
55
|
};
|
|
56
56
|
/** Fires only when the draft flips empty ⇄ non-empty, for the border color. */
|
|
57
57
|
onDraftChange?: (hasDraft: boolean) => void;
|
|
58
|
+
/**
|
|
59
|
+
* One prompt-history step, pushed by the wheel router when a notch lands on
|
|
60
|
+
* the composer band (mouse-wheel-region-routing §5.6). `nonce` is monotonic
|
|
61
|
+
* so two notches in the same direction are two steps.
|
|
62
|
+
*
|
|
63
|
+
* Applied through the SAME `verticalOrHistory` / popup-selection branches the
|
|
64
|
+
* keyboard uses, deliberately: it means the wheel and `↑`/`↓` cannot drift
|
|
65
|
+
* apart, and the "never clobber an in-progress draft" guard protects the
|
|
66
|
+
* wheel for free.
|
|
67
|
+
*/
|
|
68
|
+
historyIntent?: {
|
|
69
|
+
dir: 'up' | 'down';
|
|
70
|
+
nonce: number;
|
|
71
|
+
};
|
|
58
72
|
}
|
|
59
73
|
/** Apply an edit intent as a pure buffer/cursor transform. */
|
|
60
74
|
export declare function applyEdit(buffer: string, cursor: number, intent: EditIntent): {
|
|
@@ -84,5 +98,5 @@ export declare function fileTokenAt(buffer: string, cursor: number): {
|
|
|
84
98
|
end: number;
|
|
85
99
|
query: string;
|
|
86
100
|
} | null;
|
|
87
|
-
export declare function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, }: PromptInputProps): React.ReactElement;
|
|
101
|
+
export declare function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, historyIntent, }: PromptInputProps): React.ReactElement;
|
|
88
102
|
export {};
|
package/dist/ui/PromptInput.js
CHANGED
|
@@ -127,7 +127,7 @@ function isControlSeq(input) {
|
|
|
127
127
|
// ---------------------------------------------------------------------------
|
|
128
128
|
// Component
|
|
129
129
|
// ---------------------------------------------------------------------------
|
|
130
|
-
export function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, }) {
|
|
130
|
+
export function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, historyIntent, }) {
|
|
131
131
|
const [buffer, setBuffer] = useState('');
|
|
132
132
|
const [cursor, setCursor] = useState(0);
|
|
133
133
|
const [historyIndex, setHistoryIndex] = useState(null);
|
|
@@ -261,6 +261,43 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
261
261
|
recallDown();
|
|
262
262
|
}
|
|
263
263
|
};
|
|
264
|
+
// --- Wheel-driven history step (mouse-wheel-region-routing §5.6). ---------
|
|
265
|
+
// The applier closes over the buffer, the cursor and the popup state, so it
|
|
266
|
+
// is re-created on every render and reached through a ref: depending on it
|
|
267
|
+
// directly would re-fire the effect on every keystroke.
|
|
268
|
+
const applyHistoryIntent = useRef(() => { });
|
|
269
|
+
applyHistoryIntent.current = (dir) => {
|
|
270
|
+
// Mirrors the keyboard branch exactly — with the completion popup open,
|
|
271
|
+
// Up/Down move the selection rather than recalling history.
|
|
272
|
+
if (popupItems.length > 0) {
|
|
273
|
+
setSel(dir === 'up'
|
|
274
|
+
? Math.max(0, clampedSel - 1)
|
|
275
|
+
: Math.min(popupItems.length - 1, clampedSel + 1));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
verticalOrHistory(dir);
|
|
279
|
+
};
|
|
280
|
+
const historyIntentRef = useRef(historyIntent);
|
|
281
|
+
historyIntentRef.current = historyIntent;
|
|
282
|
+
// I-9: seeded from the incoming nonce during the FIRST render, so a mount
|
|
283
|
+
// that inherits a nonce cannot replay it. This component really does remount
|
|
284
|
+
// — `App` returns the terminal-too-small placeholder before `AppShell` —
|
|
285
|
+
// so without the guard, shrinking the window mid-wheel and growing it back
|
|
286
|
+
// recalls a history entry with no gesture behind it.
|
|
287
|
+
const lastHistoryNonce = useRef(historyIntent?.nonce);
|
|
288
|
+
const historyNonce = historyIntent?.nonce ?? 0;
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
const current = historyIntentRef.current;
|
|
291
|
+
if (!current || !isActive)
|
|
292
|
+
return;
|
|
293
|
+
if (current.nonce === lastHistoryNonce.current)
|
|
294
|
+
return;
|
|
295
|
+
lastHistoryNonce.current = current.nonce;
|
|
296
|
+
applyHistoryIntent.current(current.dir);
|
|
297
|
+
// Keyed on the nonce alone, same reasoning as `ScrollViewport`'s intent
|
|
298
|
+
// effect: `historyIntent` is a fresh object on every parent render.
|
|
299
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
300
|
+
}, [historyNonce]);
|
|
264
301
|
// --- Input handling. -----------------------------------------------------
|
|
265
302
|
useInput((input, key) => {
|
|
266
303
|
// FIRST STATEMENT, BEFORE THE POPUP BRANCH, AND IT HAS TO STAY THERE.
|
package/dist/ui/glyphs.d.ts
CHANGED
|
@@ -72,6 +72,18 @@ export interface Glyphs {
|
|
|
72
72
|
railVertical: string;
|
|
73
73
|
railBranch: string;
|
|
74
74
|
railEnd: string;
|
|
75
|
+
/**
|
|
76
|
+
* Scroll-position indicator on the viewport's right edge (mouse-wheel §4.8).
|
|
77
|
+
*
|
|
78
|
+
* These are byte-for-byte identical to `railVertical` / `gaugeFull` in both
|
|
79
|
+
* tiers, and that is DELIBERATE (P2-2). This file's header mandates "add a
|
|
80
|
+
* field here instead" of a new exception, and semantic field names are what
|
|
81
|
+
* make the ASCII tier reviewable at a glance. Reusing `gaugeFull` would
|
|
82
|
+
* couple the scroll thumb to the context-window gauge, so that restyling one
|
|
83
|
+
* silently restyles the other — do not "deduplicate" them.
|
|
84
|
+
*/
|
|
85
|
+
scrollTrack: string;
|
|
86
|
+
scrollThumb: string;
|
|
75
87
|
/** Redaction dot for `maskSecret` in the settings screen (§4.1 tier B). */
|
|
76
88
|
maskDot: string;
|
|
77
89
|
/**
|
package/dist/ui/glyphs.js
CHANGED
|
@@ -55,6 +55,8 @@ const UNICODE_GLYPHS = {
|
|
|
55
55
|
railVertical: '│',
|
|
56
56
|
railBranch: '├',
|
|
57
57
|
railEnd: '╰',
|
|
58
|
+
scrollTrack: '│',
|
|
59
|
+
scrollThumb: '█',
|
|
58
60
|
maskDot: '•',
|
|
59
61
|
boxStyle: 'round',
|
|
60
62
|
tool: {
|
|
@@ -107,6 +109,8 @@ const ASCII_GLYPHS = {
|
|
|
107
109
|
railVertical: '|',
|
|
108
110
|
railBranch: '|',
|
|
109
111
|
railEnd: '\\',
|
|
112
|
+
scrollTrack: '|',
|
|
113
|
+
scrollThumb: '#',
|
|
110
114
|
maskDot: '*',
|
|
111
115
|
boxStyle: 'classic',
|
|
112
116
|
tool: {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import React from 'react';
|
|
13
13
|
import { type RenderMode } from './frame.js';
|
|
14
|
+
import { type FrameGeometry } from './regions.js';
|
|
14
15
|
export interface AppShellProps {
|
|
15
16
|
mode: RenderMode;
|
|
16
17
|
rows: number;
|
|
@@ -20,5 +21,13 @@ export interface AppShellProps {
|
|
|
20
21
|
toast: React.ReactNode;
|
|
21
22
|
composer: React.ReactNode;
|
|
22
23
|
status: React.ReactNode;
|
|
24
|
+
/**
|
|
25
|
+
* Where the wheel router reads the composer band boundary from (mouse-wheel
|
|
26
|
+
* §4.5). Written in a LAYOUT EFFECT to a MUTABLE REF, never to state: the
|
|
27
|
+
* measurement happens on every commit, and routing it through `setState`
|
|
28
|
+
* would add a re-render to the streaming path for a value nothing renders
|
|
29
|
+
* (R-7).
|
|
30
|
+
*/
|
|
31
|
+
geometryRef?: React.MutableRefObject<FrameGeometry>;
|
|
23
32
|
}
|
|
24
|
-
export declare function AppShell({ mode, rows, cols, header, viewport, toast, composer, status, }: AppShellProps): React.ReactElement;
|
|
33
|
+
export declare function AppShell({ mode, rows, cols, header, viewport, toast, composer, status, geometryRef, }: AppShellProps): React.ReactElement;
|
|
@@ -1,9 +1,44 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* AppShell (spec §4.3) — the frame that makes R2 true.
|
|
4
|
+
*
|
|
5
|
+
* In `fullscreen` the root box has a FIXED height of `frameHeight(rows)` and the
|
|
6
|
+
* middle viewport is the only `flexGrow` child, so the bottom chrome (toast /
|
|
7
|
+
* composer / status bar) is physically the last thing on the screen from the
|
|
8
|
+
* very first frame — empty session, long session, mid-scroll, overlay open.
|
|
9
|
+
*
|
|
10
|
+
* In `inline` the same children are emitted in the same order with no height
|
|
11
|
+
* constraint, i.e. byte-for-byte the v0.2.0 document-flow behavior.
|
|
12
|
+
*/
|
|
13
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
14
|
+
import { Box, measureElement } from 'ink';
|
|
3
15
|
import { frameHeight } from './frame.js';
|
|
4
|
-
|
|
16
|
+
import { UNMEASURED_GEOMETRY } from './regions.js';
|
|
17
|
+
export function AppShell({ mode, rows, cols, header, viewport, toast, composer, status, geometryRef, }) {
|
|
18
|
+
const bottomRef = useRef(null);
|
|
19
|
+
// DECLARED ABOVE THE INLINE EARLY RETURN, and it has to stay there: React
|
|
20
|
+
// keys hooks by call order, and a hook that only runs in one mode changes
|
|
21
|
+
// that order between modes.
|
|
22
|
+
//
|
|
23
|
+
// Ink computes yoga layout in `onRender`, which React runs BEFORE layout
|
|
24
|
+
// effects, so this reflects the commit that just happened — the same
|
|
25
|
+
// mechanism `ScrollViewport` already relies on.
|
|
26
|
+
useLayoutEffect(() => {
|
|
27
|
+
if (!geometryRef)
|
|
28
|
+
return;
|
|
29
|
+
if (mode === 'inline' || !bottomRef.current) {
|
|
30
|
+
geometryRef.current = UNMEASURED_GEOMETRY;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
geometryRef.current = {
|
|
34
|
+
// 1 in the alternate screen — see the note on `FrameGeometry.top`.
|
|
35
|
+
top: 1,
|
|
36
|
+
height: frameHeight(rows),
|
|
37
|
+
bottom: measureElement(bottomRef.current).height,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
5
40
|
if (mode === 'inline') {
|
|
6
41
|
return (_jsxs(Box, { flexDirection: "column", children: [header, viewport, toast, composer, status] }));
|
|
7
42
|
}
|
|
8
|
-
return (_jsxs(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexShrink: 0, children: header }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: viewport }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [toast, composer, status] })] }));
|
|
43
|
+
return (_jsxs(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexShrink: 0, children: header }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: viewport }), _jsxs(Box, { ref: bottomRef, flexDirection: "column", flexShrink: 0, children: [toast, composer, status] })] }));
|
|
9
44
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScrollIndicator (mouse-wheel-region-routing §4.8) — the one-column scroll
|
|
3
|
+
* position rail on the viewport's right edge.
|
|
4
|
+
*
|
|
5
|
+
* NAMING (P2-1): this is NOT `layout/Gutter.tsx`. That one is the two-column
|
|
6
|
+
* left rail every transcript entry sits in, and it carries an invariant of its
|
|
7
|
+
* own. Two components called a gutter, on opposite edges, for unrelated
|
|
8
|
+
* purposes, is how a codebase stops being able to say what it means.
|
|
9
|
+
*
|
|
10
|
+
* THE COLUMN IS RESERVED UNCONDITIONALLY, track glyph and all. Showing it only
|
|
11
|
+
* while scrolled would change the content width, re-wrap every line, change the
|
|
12
|
+
* content height, and feed that back into the measurement that decides whether
|
|
13
|
+
* to show it — a shimmer at the exact moment the user is reading. Constant
|
|
14
|
+
* width, no feedback loop (D-9); `width={1}` and `flexShrink={0}` hold the
|
|
15
|
+
* column even on the first frame, before anything has been measured.
|
|
16
|
+
*/
|
|
17
|
+
import React from 'react';
|
|
18
|
+
import type { Theme } from '../theme.js';
|
|
19
|
+
import type { TermCapabilities } from '../capabilities.js';
|
|
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;
|
|
23
|
+
export interface ScrollIndicatorProps {
|
|
24
|
+
/** Viewport height in rows, as measured by `ScrollViewport`. */
|
|
25
|
+
rows: number;
|
|
26
|
+
/** `null` when the content fits — the rail is drawn as bare track. */
|
|
27
|
+
thumb: ThumbRange | null;
|
|
28
|
+
theme: Theme;
|
|
29
|
+
caps: TermCapabilities;
|
|
30
|
+
}
|
|
31
|
+
export declare function ScrollIndicator({ rows, thumb, theme, caps, }: ScrollIndicatorProps): React.ReactElement;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
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, }) {
|
|
7
|
+
const glyphs = pickGlyphs(caps);
|
|
8
|
+
const count = Math.max(0, Math.floor(rows) || 0);
|
|
9
|
+
const cells = [];
|
|
10
|
+
for (let i = 0; i < count; i += 1) {
|
|
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));
|
|
13
|
+
}
|
|
14
|
+
return (_jsx(Box, { flexDirection: "column", flexShrink: 0, width: 1, children: cells }));
|
|
15
|
+
}
|
|
@@ -17,17 +17,23 @@ import type { Theme } from '../theme.js';
|
|
|
17
17
|
import type { TermCapabilities } from '../capabilities.js';
|
|
18
18
|
import { type ScrollIntent } from './scroll.js';
|
|
19
19
|
export interface ScrollViewportProps {
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Scroll intent. `nonce` is monotonic so pressing the same key twice re-fires.
|
|
22
|
+
* `repeat` folds a coalesced wheel burst into one state update (defaults 1).
|
|
23
|
+
*/
|
|
21
24
|
intent?: {
|
|
22
25
|
kind: ScrollIntent;
|
|
23
26
|
nonce: number;
|
|
27
|
+
repeat?: number;
|
|
24
28
|
};
|
|
25
29
|
/** Bumped by the parent to force a pin to the newest output (e.g. on submit). */
|
|
26
30
|
pinToBottomNonce?: number;
|
|
27
31
|
/** Derived display value for the status bar's `↑N`. Must be referentially stable. */
|
|
28
32
|
onScrolledLinesChange?: (lines: number) => void;
|
|
33
|
+
/** Reserve the right-edge scroll-position rail (mouse-wheel §4.8). */
|
|
34
|
+
showScrollIndicator?: boolean;
|
|
29
35
|
theme: Theme;
|
|
30
36
|
caps: TermCapabilities;
|
|
31
37
|
children: React.ReactNode;
|
|
32
38
|
}
|
|
33
|
-
export declare function ScrollViewport({ intent, pinToBottomNonce, onScrolledLinesChange, theme, caps, children, }: ScrollViewportProps): React.ReactElement;
|
|
39
|
+
export declare function ScrollViewport({ intent, pinToBottomNonce, onScrolledLinesChange, showScrollIndicator, theme, caps, children, }: ScrollViewportProps): React.ReactElement;
|