@aragon-agent/cli 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +292 -0
- package/README.md +558 -0
- package/dist/agent/coalesce.d.ts +14 -0
- package/dist/agent/coalesce.js +41 -0
- package/dist/agent/controller.d.ts +132 -0
- package/dist/agent/controller.js +332 -0
- package/dist/agent/headless.d.ts +22 -0
- package/dist/agent/headless.js +74 -0
- package/dist/agent/reducer.d.ts +181 -0
- package/dist/agent/reducer.js +420 -0
- package/dist/agent/system-prompt.d.ts +30 -0
- package/dist/agent/system-prompt.js +58 -0
- package/dist/agent/usage.d.ts +13 -0
- package/dist/agent/usage.js +41 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +482 -0
- package/dist/commands/builtins.d.ts +16 -0
- package/dist/commands/builtins.js +223 -0
- package/dist/commands/registry.d.ts +48 -0
- package/dist/commands/registry.js +45 -0
- package/dist/commands/skills.d.ts +22 -0
- package/dist/commands/skills.js +625 -0
- package/dist/config/app-paths.d.ts +11 -0
- package/dist/config/app-paths.js +16 -0
- package/dist/config/env.d.ts +28 -0
- package/dist/config/env.js +92 -0
- package/dist/config/load.d.ts +48 -0
- package/dist/config/load.js +158 -0
- package/dist/config/migrate-legacy-state.d.ts +78 -0
- package/dist/config/migrate-legacy-state.js +181 -0
- package/dist/config/schema.d.ts +249 -0
- package/dist/config/schema.js +247 -0
- package/dist/config/store.d.ts +44 -0
- package/dist/config/store.js +126 -0
- package/dist/input/keymap.d.ts +20 -0
- package/dist/input/keymap.js +85 -0
- package/dist/session/persist.d.ts +24 -0
- package/dist/session/persist.js +51 -0
- package/dist/skills/archive.d.ts +41 -0
- package/dist/skills/archive.js +152 -0
- package/dist/skills/cli-commands.d.ts +52 -0
- package/dist/skills/cli-commands.js +502 -0
- package/dist/skills/fetch-source.d.ts +104 -0
- package/dist/skills/fetch-source.js +448 -0
- package/dist/skills/installer.d.ts +117 -0
- package/dist/skills/installer.js +414 -0
- package/dist/skills/lock.d.ts +75 -0
- package/dist/skills/lock.js +223 -0
- package/dist/skills/manifest.d.ts +47 -0
- package/dist/skills/manifest.js +95 -0
- package/dist/skills/node-host.d.ts +23 -0
- package/dist/skills/node-host.js +59 -0
- package/dist/skills/paths.d.ts +61 -0
- package/dist/skills/paths.js +107 -0
- package/dist/skills/service.d.ts +256 -0
- package/dist/skills/service.js +730 -0
- package/dist/skills/tools.d.ts +35 -0
- package/dist/skills/tools.js +164 -0
- package/dist/skills/updater.d.ts +101 -0
- package/dist/skills/updater.js +330 -0
- package/dist/skills/usage.d.ts +72 -0
- package/dist/skills/usage.js +216 -0
- package/dist/tools/bash-tool.d.ts +12 -0
- package/dist/tools/bash-tool.js +131 -0
- package/dist/tools/diff.d.ts +15 -0
- package/dist/tools/diff.js +50 -0
- package/dist/tools/fs-tools.d.ts +16 -0
- package/dist/tools/fs-tools.js +215 -0
- package/dist/tools/index.d.ts +93 -0
- package/dist/tools/index.js +157 -0
- package/dist/tools/search-tools.d.ts +11 -0
- package/dist/tools/search-tools.js +225 -0
- package/dist/ui/App.d.ts +24 -0
- package/dist/ui/App.js +522 -0
- package/dist/ui/AutocompletePopup.d.ts +21 -0
- package/dist/ui/AutocompletePopup.js +19 -0
- package/dist/ui/Composer.d.ts +40 -0
- package/dist/ui/Composer.js +61 -0
- package/dist/ui/Header.d.ts +30 -0
- package/dist/ui/Header.js +25 -0
- package/dist/ui/Logo.d.ts +50 -0
- package/dist/ui/Logo.js +85 -0
- package/dist/ui/Markdown.d.ts +23 -0
- package/dist/ui/Markdown.js +122 -0
- package/dist/ui/PromptInput.d.ts +81 -0
- package/dist/ui/PromptInput.js +383 -0
- package/dist/ui/SessionOpener.d.ts +30 -0
- package/dist/ui/SessionOpener.js +12 -0
- package/dist/ui/StatusBar.d.ts +39 -0
- package/dist/ui/StatusBar.js +43 -0
- package/dist/ui/ToastStack.d.ts +24 -0
- package/dist/ui/ToastStack.js +38 -0
- package/dist/ui/Transcript.d.ts +62 -0
- package/dist/ui/Transcript.js +128 -0
- package/dist/ui/capabilities.d.ts +25 -0
- package/dist/ui/capabilities.js +59 -0
- package/dist/ui/console-bridge.d.ts +19 -0
- package/dist/ui/console-bridge.js +52 -0
- package/dist/ui/density.d.ts +26 -0
- package/dist/ui/density.js +34 -0
- package/dist/ui/entries/AssistantEntry.d.ts +23 -0
- package/dist/ui/entries/AssistantEntry.js +13 -0
- package/dist/ui/entries/EntryFrame.d.ts +23 -0
- package/dist/ui/entries/EntryFrame.js +6 -0
- package/dist/ui/entries/ToolCard.d.ts +38 -0
- package/dist/ui/entries/ToolCard.js +72 -0
- package/dist/ui/entries/ToolPreview.d.ts +57 -0
- package/dist/ui/entries/ToolPreview.js +144 -0
- package/dist/ui/entries/UserEntry.d.ts +14 -0
- package/dist/ui/entries/UserEntry.js +5 -0
- package/dist/ui/exit-snapshot.d.ts +27 -0
- package/dist/ui/exit-snapshot.js +26 -0
- package/dist/ui/gauge.d.ts +24 -0
- package/dist/ui/gauge.js +36 -0
- package/dist/ui/glyphs.d.ts +87 -0
- package/dist/ui/glyphs.js +127 -0
- package/dist/ui/gradient.d.ts +11 -0
- package/dist/ui/gradient.js +50 -0
- package/dist/ui/layout/AppShell.d.ts +24 -0
- package/dist/ui/layout/AppShell.js +9 -0
- package/dist/ui/layout/Gutter.d.ts +67 -0
- package/dist/ui/layout/Gutter.js +42 -0
- package/dist/ui/layout/OverlayFrame.d.ts +62 -0
- package/dist/ui/layout/OverlayFrame.js +92 -0
- package/dist/ui/layout/ScrollViewport.d.ts +33 -0
- package/dist/ui/layout/ScrollViewport.js +71 -0
- package/dist/ui/layout/budget.d.ts +42 -0
- package/dist/ui/layout/budget.js +47 -0
- package/dist/ui/layout/frame.d.ts +58 -0
- package/dist/ui/layout/frame.js +75 -0
- package/dist/ui/layout/overlay-window.d.ts +43 -0
- package/dist/ui/layout/overlay-window.js +51 -0
- package/dist/ui/layout/scroll.d.ts +28 -0
- package/dist/ui/layout/scroll.js +76 -0
- package/dist/ui/layout/useTerminalSize.d.ts +13 -0
- package/dist/ui/layout/useTerminalSize.js +50 -0
- package/dist/ui/markdown-blocks.d.ts +62 -0
- package/dist/ui/markdown-blocks.js +120 -0
- package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
- package/dist/ui/overlays/ConfirmDialog.js +25 -0
- package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
- package/dist/ui/overlays/HelpOverlay.js +60 -0
- package/dist/ui/overlays/ModelPicker.d.ts +29 -0
- package/dist/ui/overlays/ModelPicker.js +29 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
- package/dist/ui/overlays/SettingsScreen.js +106 -0
- package/dist/ui/palettes.d.ts +79 -0
- package/dist/ui/palettes.js +115 -0
- package/dist/ui/screen.d.ts +27 -0
- package/dist/ui/screen.js +57 -0
- package/dist/ui/theme.d.ts +78 -0
- package/dist/ui/theme.js +127 -0
- package/dist/ui/transcript-text.d.ts +27 -0
- package/dist/ui/transcript-text.js +110 -0
- package/package.json +73 -0
- package/skills/skill-creator/SKILL.md +96 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/** Columns reserved for the rail, glyph included. */
|
|
4
|
+
export const GUTTER_WIDTH = 2;
|
|
5
|
+
export function Gutter({ glyph, color, children }) {
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, width: GUTTER_WIDTH, children: _jsx(Text, { color: color, children: glyph }) }), _jsx(Box, { flexDirection: "column", flexShrink: 0, children: children })] }));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The one-row gap between turns: a dim rail stub rather than a blank line, so
|
|
10
|
+
* the eye still follows a continuous left edge across the turn boundary.
|
|
11
|
+
*/
|
|
12
|
+
export function RailSpacer({ continuation, color, }) {
|
|
13
|
+
return (_jsx(Text, { color: color, children: continuation }));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A left-only border whose vertical character is the rail glyph.
|
|
17
|
+
*
|
|
18
|
+
* Used where a block owns its own lines (tool preview, fenced code) and used to
|
|
19
|
+
* be wrapped in a round box: the border cost 2 rows plus 2 columns of padding
|
|
20
|
+
* and nested visibly inside other round boxes. Ink repeats `box.left` for the
|
|
21
|
+
* measured height of the child, so this is a continuous rail that costs 1 column
|
|
22
|
+
* and 0 rows.
|
|
23
|
+
*/
|
|
24
|
+
export function railBorderProps(rail, color) {
|
|
25
|
+
return {
|
|
26
|
+
borderStyle: {
|
|
27
|
+
topLeft: rail,
|
|
28
|
+
top: '',
|
|
29
|
+
topRight: '',
|
|
30
|
+
right: '',
|
|
31
|
+
bottomRight: '',
|
|
32
|
+
bottom: '',
|
|
33
|
+
bottomLeft: rail,
|
|
34
|
+
left: rail,
|
|
35
|
+
},
|
|
36
|
+
borderTop: false,
|
|
37
|
+
borderBottom: false,
|
|
38
|
+
borderRight: false,
|
|
39
|
+
borderLeft: true,
|
|
40
|
+
borderColor: color,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OverlayFrame (spec §4.4) — the shared shell for all four overlays.
|
|
3
|
+
*
|
|
4
|
+
* TWO CONTENT MODES, and the distinction is load-bearing:
|
|
5
|
+
*
|
|
6
|
+
* - mode A (`rows`): the caller hands over an array of elements that are each
|
|
7
|
+
* exactly one row and `wrap="truncate"`. The frame slices them and OWNS the
|
|
8
|
+
* scroll keys. Used by Help and Settings.
|
|
9
|
+
*
|
|
10
|
+
* - mode B (`children`): the content manages itself. The frame draws the title,
|
|
11
|
+
* the border and the footer, and touches NOTHING else — no slicing, no
|
|
12
|
+
* arrow-key handling, no position indicator.
|
|
13
|
+
*
|
|
14
|
+
* Mode B exists because of `ink-select-input`: it already windows itself via
|
|
15
|
+
* `limit` and registers its own `useInput` with `isFocused` defaulting to true.
|
|
16
|
+
* Ink dispatches a key to EVERY mounted input handler, so a frame that also
|
|
17
|
+
* claimed the arrow keys would move the model picker's selection twice per press
|
|
18
|
+
* — breaking a feature that already works. Slicing it by element would be worse
|
|
19
|
+
* still, since it is a single component rather than a list of rows.
|
|
20
|
+
*
|
|
21
|
+
* `maxRows` may be `Infinity` (the inline render path, which has no fixed frame
|
|
22
|
+
* and therefore no height to fit): the frame then renders everything and shows
|
|
23
|
+
* no position indicator.
|
|
24
|
+
*/
|
|
25
|
+
import React from 'react';
|
|
26
|
+
import type { Theme } from '../theme.js';
|
|
27
|
+
import type { TermCapabilities } from '../capabilities.js';
|
|
28
|
+
interface OverlayFrameBase {
|
|
29
|
+
title: string;
|
|
30
|
+
/** Footer text. Written by the CALLER so each overlay names its own keys. */
|
|
31
|
+
hint: string;
|
|
32
|
+
/** Viewport rows the overlay may occupy; `Infinity` in inline mode. */
|
|
33
|
+
maxRows: number;
|
|
34
|
+
cols?: number;
|
|
35
|
+
theme: Theme;
|
|
36
|
+
caps: TermCapabilities;
|
|
37
|
+
}
|
|
38
|
+
interface OverlayFrameControlled extends OverlayFrameBase {
|
|
39
|
+
/** One element per row, each already `wrap="truncate"`. */
|
|
40
|
+
rows: readonly React.ReactElement[];
|
|
41
|
+
scrollOffset: number;
|
|
42
|
+
/**
|
|
43
|
+
* Report the offset back after clamping.
|
|
44
|
+
*
|
|
45
|
+
* `sliceWindow` clamps for DISPLAY, but the owner of the state does not learn
|
|
46
|
+
* about it. A caller that just keeps adding a page per `PgDn` therefore ends
|
|
47
|
+
* up parked far past the end, and the first several `PgUp` presses then move a
|
|
48
|
+
* counter nobody can see while the screen sits still — the overlay reads as
|
|
49
|
+
* frozen. Feeding the clamped value back keeps the two in step.
|
|
50
|
+
*/
|
|
51
|
+
onScrollClamp?: (offset: number) => void;
|
|
52
|
+
children?: undefined;
|
|
53
|
+
}
|
|
54
|
+
interface OverlayFrameSelfManaged extends OverlayFrameBase {
|
|
55
|
+
rows?: undefined;
|
|
56
|
+
scrollOffset?: undefined;
|
|
57
|
+
onScrollClamp?: undefined;
|
|
58
|
+
children: React.ReactNode;
|
|
59
|
+
}
|
|
60
|
+
export type OverlayFrameProps = OverlayFrameControlled | OverlayFrameSelfManaged;
|
|
61
|
+
export declare function OverlayFrame(props: OverlayFrameProps): React.ReactElement;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* OverlayFrame (spec §4.4) — the shared shell for all four overlays.
|
|
4
|
+
*
|
|
5
|
+
* TWO CONTENT MODES, and the distinction is load-bearing:
|
|
6
|
+
*
|
|
7
|
+
* - mode A (`rows`): the caller hands over an array of elements that are each
|
|
8
|
+
* exactly one row and `wrap="truncate"`. The frame slices them and OWNS the
|
|
9
|
+
* scroll keys. Used by Help and Settings.
|
|
10
|
+
*
|
|
11
|
+
* - mode B (`children`): the content manages itself. The frame draws the title,
|
|
12
|
+
* the border and the footer, and touches NOTHING else — no slicing, no
|
|
13
|
+
* arrow-key handling, no position indicator.
|
|
14
|
+
*
|
|
15
|
+
* Mode B exists because of `ink-select-input`: it already windows itself via
|
|
16
|
+
* `limit` and registers its own `useInput` with `isFocused` defaulting to true.
|
|
17
|
+
* Ink dispatches a key to EVERY mounted input handler, so a frame that also
|
|
18
|
+
* claimed the arrow keys would move the model picker's selection twice per press
|
|
19
|
+
* — breaking a feature that already works. Slicing it by element would be worse
|
|
20
|
+
* still, since it is a single component rather than a list of rows.
|
|
21
|
+
*
|
|
22
|
+
* `maxRows` may be `Infinity` (the inline render path, which has no fixed frame
|
|
23
|
+
* and therefore no height to fit): the frame then renders everything and shows
|
|
24
|
+
* no position indicator.
|
|
25
|
+
*/
|
|
26
|
+
import { useEffect } from 'react';
|
|
27
|
+
import { Box, Text } from 'ink';
|
|
28
|
+
import { pickGlyphs } from '../glyphs.js';
|
|
29
|
+
import { sliceWindow } from './overlay-window.js';
|
|
30
|
+
/** Below this width the round border costs more than it conveys (§5.2). */
|
|
31
|
+
const BORDERLESS_COLS = 48;
|
|
32
|
+
/** Title + footer + top and bottom border. */
|
|
33
|
+
const CHROME_ROWS = 4;
|
|
34
|
+
/**
|
|
35
|
+
* The frame's own `marginTop`, which is spent out of the SAME `maxRows` budget.
|
|
36
|
+
*
|
|
37
|
+
* Leaving it out makes the box render one row taller than the viewport it sits
|
|
38
|
+
* in, and the viewport's `overflow: hidden` then eats the bottom border — an
|
|
39
|
+
* overlay silently clipped by exactly the mechanism this round exists to remove.
|
|
40
|
+
* Verified by `overlay-frame-height.test.tsx`.
|
|
41
|
+
*/
|
|
42
|
+
const MARGIN_ROWS = 1;
|
|
43
|
+
export function OverlayFrame(props) {
|
|
44
|
+
const { title, hint, maxRows, cols = 80, theme, caps } = props;
|
|
45
|
+
const glyphs = pickGlyphs(caps);
|
|
46
|
+
const bordered = cols >= BORDERLESS_COLS;
|
|
47
|
+
const unbounded = !Number.isFinite(maxRows);
|
|
48
|
+
let body;
|
|
49
|
+
let position = '';
|
|
50
|
+
/** Set only in controlled mode; `null` means "nothing to report back". */
|
|
51
|
+
let overflowOffset = null;
|
|
52
|
+
if (props.rows) {
|
|
53
|
+
const chrome = MARGIN_ROWS + (bordered ? CHROME_ROWS : CHROME_ROWS - 2);
|
|
54
|
+
const height = unbounded ? props.rows.length : Math.max(1, Math.floor(maxRows) - chrome);
|
|
55
|
+
const win = sliceWindow(props.rows.length, props.scrollOffset, height);
|
|
56
|
+
body = props.rows.slice(win.start, win.start + win.visible);
|
|
57
|
+
if (win.clamped !== props.scrollOffset)
|
|
58
|
+
overflowOffset = win.clamped;
|
|
59
|
+
if (!unbounded && win.visible < win.total) {
|
|
60
|
+
const up = win.start > 0 ? glyphs.arrowUp : ' ';
|
|
61
|
+
const down = win.start + win.visible < win.total ? glyphs.arrowDown : ' ';
|
|
62
|
+
position = ` ${up}${down} ${win.start + 1}-${win.start + win.visible}/${win.total}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
body = props.children;
|
|
67
|
+
}
|
|
68
|
+
// In an effect, not in the branch above: calling the owner's setter mid-render
|
|
69
|
+
// updates another component while this one renders. Clamping is idempotent, so
|
|
70
|
+
// this settles after one extra pass.
|
|
71
|
+
const onScrollClamp = props.onScrollClamp;
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (overflowOffset !== null)
|
|
74
|
+
onScrollClamp?.(overflowOffset);
|
|
75
|
+
}, [overflowOffset, onScrollClamp]);
|
|
76
|
+
const inner = (_jsxs(_Fragment, { children: [_jsx(Text, { wrap: "truncate", color: theme.primary, bold: true, children: title }), body, _jsxs(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: [hint, position] })] }));
|
|
77
|
+
if (!bordered) {
|
|
78
|
+
// Narrow terminals trade the box for a rail: same grouping, 2 fewer rows
|
|
79
|
+
// and 4 fewer columns (§5.2).
|
|
80
|
+
return (_jsx(Box, { flexDirection: "column", flexShrink: 0, marginTop: 1, children: _jsx(Box, { flexDirection: "column", flexShrink: 0, borderStyle: {
|
|
81
|
+
topLeft: glyphs.railVertical,
|
|
82
|
+
top: '',
|
|
83
|
+
topRight: '',
|
|
84
|
+
right: '',
|
|
85
|
+
bottomRight: '',
|
|
86
|
+
bottom: '',
|
|
87
|
+
bottomLeft: glyphs.railVertical,
|
|
88
|
+
left: glyphs.railVertical,
|
|
89
|
+
}, borderTop: false, borderBottom: false, borderRight: false, borderColor: theme.primary, paddingLeft: 1, children: inner }) }));
|
|
90
|
+
}
|
|
91
|
+
return (_jsx(Box, { flexDirection: "column", flexShrink: 0, borderStyle: glyphs.boxStyle, borderColor: theme.primary, paddingX: 1, marginTop: 1, children: inner }));
|
|
92
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
import React from 'react';
|
|
16
|
+
import type { Theme } from '../theme.js';
|
|
17
|
+
import type { TermCapabilities } from '../capabilities.js';
|
|
18
|
+
import { type ScrollIntent } from './scroll.js';
|
|
19
|
+
export interface ScrollViewportProps {
|
|
20
|
+
/** Scroll intent. `nonce` is monotonic so pressing the same key twice re-fires. */
|
|
21
|
+
intent?: {
|
|
22
|
+
kind: ScrollIntent;
|
|
23
|
+
nonce: number;
|
|
24
|
+
};
|
|
25
|
+
/** Bumped by the parent to force a pin to the newest output (e.g. on submit). */
|
|
26
|
+
pinToBottomNonce?: number;
|
|
27
|
+
/** Derived display value for the status bar's `↑N`. Must be referentially stable. */
|
|
28
|
+
onScrolledLinesChange?: (lines: number) => void;
|
|
29
|
+
theme: Theme;
|
|
30
|
+
caps: TermCapabilities;
|
|
31
|
+
children: React.ReactNode;
|
|
32
|
+
}
|
|
33
|
+
export declare function ScrollViewport({ intent, pinToBottomNonce, onScrolledLinesChange, theme, caps, children, }: ScrollViewportProps): React.ReactElement;
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
17
|
+
import { Box, Text, measureElement, useStdout } from 'ink';
|
|
18
|
+
import { pickGlyphs } from '../glyphs.js';
|
|
19
|
+
import { applyScroll, clampScroll, } from './scroll.js';
|
|
20
|
+
/** Below this the full hint sentence wraps and eats a second viewport row. */
|
|
21
|
+
const TERSE_HINT_COLS = 42;
|
|
22
|
+
export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesChange, theme, caps, children, }) {
|
|
23
|
+
const { stdout } = useStdout();
|
|
24
|
+
const cols = stdout?.columns ?? 80;
|
|
25
|
+
const glyphs = pickGlyphs(caps);
|
|
26
|
+
const clipRef = useRef(null);
|
|
27
|
+
const innerRef = useRef(null);
|
|
28
|
+
const [metrics, setMetrics] = useState({ viewport: 0, content: 0 });
|
|
29
|
+
const [scroll, setScroll] = useState({ offset: 0 });
|
|
30
|
+
const metricsRef = useRef(metrics);
|
|
31
|
+
metricsRef.current = metrics;
|
|
32
|
+
const intentRef = useRef(intent);
|
|
33
|
+
intentRef.current = intent;
|
|
34
|
+
// Ink calculates yoga layout in `onRender`, which React runs BEFORE layout
|
|
35
|
+
// effects, so the measurement below reflects the commit that just happened.
|
|
36
|
+
useLayoutEffect(() => {
|
|
37
|
+
const viewport = clipRef.current ? measureElement(clipRef.current).height : 0;
|
|
38
|
+
const content = innerRef.current ? measureElement(innerRef.current).height : 0;
|
|
39
|
+
setMetrics((prev) => (prev.viewport === viewport && prev.content === content ? prev : { viewport, content }));
|
|
40
|
+
});
|
|
41
|
+
const intentNonce = intent?.nonce ?? 0;
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const current = intentRef.current;
|
|
44
|
+
if (!current)
|
|
45
|
+
return;
|
|
46
|
+
setScroll((prev) => applyScroll(prev, current.kind, metricsRef.current));
|
|
47
|
+
// Keyed on the nonce alone: `intent` is a fresh object on every parent
|
|
48
|
+
// render, so depending on it directly would re-scroll on every keystroke.
|
|
49
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
50
|
+
}, [intentNonce]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
setScroll({ offset: 0 });
|
|
53
|
+
}, [pinToBottomNonce]);
|
|
54
|
+
const overflowLines = Math.max(0, metrics.content - metrics.viewport);
|
|
55
|
+
const offset = clampScroll(scroll.offset, overflowLines);
|
|
56
|
+
const shiftUp = overflowLines - offset;
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
onScrolledLinesChange?.(offset);
|
|
59
|
+
}, [offset, onScrolledLinesChange]);
|
|
60
|
+
// `offset` is exactly the count of rows hidden below the viewport — that is
|
|
61
|
+
// what "N new lines you have not scrolled down to" means.
|
|
62
|
+
const hiddenBelow = offset;
|
|
63
|
+
const showHint = hiddenBelow > 0;
|
|
64
|
+
return (_jsxs(Box, { flexDirection: "column", 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 }) }), showHint && (
|
|
65
|
+
// Not an overlay — Ink has no absolute positioning or z-index. This takes
|
|
66
|
+
// the viewport's last row, trading one line of content for a hint that
|
|
67
|
+
// cannot drift. Cheaper than stacking with negative margins.
|
|
68
|
+
_jsx(Box, { flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: cols < TERSE_HINT_COLS
|
|
69
|
+
? `${glyphs.arrowDown}${hiddenBelow}`
|
|
70
|
+
: `${glyphs.arrowDown} ${hiddenBelow} new ${hiddenBelow === 1 ? 'line' : 'lines'} ${glyphs.midDot} PgDn for the latest` }) }))] }));
|
|
71
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout budget (spec §4.2a) — the ONE function that says how many rows the
|
|
3
|
+
* viewport gets.
|
|
4
|
+
*
|
|
5
|
+
* Before v0.4.0 this number was computed nowhere and assumed in three places,
|
|
6
|
+
* which is how the viewport ended up NON-MONOTONIC in `rows`: growing the
|
|
7
|
+
* terminal from 27 to 28 rows promoted the header to the 8-row wordmark tier and
|
|
8
|
+
* the visible content SHRANK from 18 rows to 13. Making the brand region a
|
|
9
|
+
* constant 1 row (§4.2) is what fixes that, and this module is where the fix is
|
|
10
|
+
* expressed as arithmetic so `budget.test.ts` can pin it.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately NOT part of `frame.ts`: that file is the read-only foundation
|
|
13
|
+
* (invariant I-1) and this is content-layer policy.
|
|
14
|
+
*
|
|
15
|
+
* Scope: the STATIC chrome only. Two consumers are dynamic and are handled by
|
|
16
|
+
* the degradation ladder in §5.2 instead of being averaged in here:
|
|
17
|
+
* - `ScrollViewport`'s "N new lines" hint takes the viewport's own last row;
|
|
18
|
+
* - `AutocompletePopup` expands the bottom chrome by up to 9 rows.
|
|
19
|
+
*/
|
|
20
|
+
/** Below this many rows the composer's hint line is dropped to buy back a row. */
|
|
21
|
+
export declare const HINT_MIN_ROWS = 20;
|
|
22
|
+
export interface ChromeBudget {
|
|
23
|
+
/** Brand bar. Constant 1 in full-screen — this is what makes A-3 hold. */
|
|
24
|
+
header: number;
|
|
25
|
+
toast: number;
|
|
26
|
+
/** Round border (2) + input row (1), plus the hint row when it fits. */
|
|
27
|
+
composer: number;
|
|
28
|
+
status: number;
|
|
29
|
+
}
|
|
30
|
+
export declare function chromeBudget(rows: number): ChromeBudget;
|
|
31
|
+
/**
|
|
32
|
+
* Rows available to the viewport at a given terminal height.
|
|
33
|
+
*
|
|
34
|
+
* Note what is NOT a parameter: whether the transcript is empty. The absence of
|
|
35
|
+
* that argument is the guarantee that the first submitted message cannot make
|
|
36
|
+
* the layout jump (A-4). Do not add it back.
|
|
37
|
+
*
|
|
38
|
+
* Non-decreasing in `rows`: the one step where chrome grows (the hint row at
|
|
39
|
+
* `HINT_MIN_ROWS`) coincides with `rows` growing by one, so the net change is 0
|
|
40
|
+
* rather than -1.
|
|
41
|
+
*/
|
|
42
|
+
export declare function viewportRows(rows: number): number;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout budget (spec §4.2a) — the ONE function that says how many rows the
|
|
3
|
+
* viewport gets.
|
|
4
|
+
*
|
|
5
|
+
* Before v0.4.0 this number was computed nowhere and assumed in three places,
|
|
6
|
+
* which is how the viewport ended up NON-MONOTONIC in `rows`: growing the
|
|
7
|
+
* terminal from 27 to 28 rows promoted the header to the 8-row wordmark tier and
|
|
8
|
+
* the visible content SHRANK from 18 rows to 13. Making the brand region a
|
|
9
|
+
* constant 1 row (§4.2) is what fixes that, and this module is where the fix is
|
|
10
|
+
* expressed as arithmetic so `budget.test.ts` can pin it.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately NOT part of `frame.ts`: that file is the read-only foundation
|
|
13
|
+
* (invariant I-1) and this is content-layer policy.
|
|
14
|
+
*
|
|
15
|
+
* Scope: the STATIC chrome only. Two consumers are dynamic and are handled by
|
|
16
|
+
* the degradation ladder in §5.2 instead of being averaged in here:
|
|
17
|
+
* - `ScrollViewport`'s "N new lines" hint takes the viewport's own last row;
|
|
18
|
+
* - `AutocompletePopup` expands the bottom chrome by up to 9 rows.
|
|
19
|
+
*/
|
|
20
|
+
import { frameHeight, MIN_FULLSCREEN_ROWS } from './frame.js';
|
|
21
|
+
/** Below this many rows the composer's hint line is dropped to buy back a row. */
|
|
22
|
+
export const HINT_MIN_ROWS = 20;
|
|
23
|
+
export function chromeBudget(rows) {
|
|
24
|
+
return {
|
|
25
|
+
header: 1,
|
|
26
|
+
toast: 1,
|
|
27
|
+
composer: rows >= HINT_MIN_ROWS ? 4 : 3,
|
|
28
|
+
status: 1,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Rows available to the viewport at a given terminal height.
|
|
33
|
+
*
|
|
34
|
+
* Note what is NOT a parameter: whether the transcript is empty. The absence of
|
|
35
|
+
* that argument is the guarantee that the first submitted message cannot make
|
|
36
|
+
* the layout jump (A-4). Do not add it back.
|
|
37
|
+
*
|
|
38
|
+
* Non-decreasing in `rows`: the one step where chrome grows (the hint row at
|
|
39
|
+
* `HINT_MIN_ROWS`) coincides with `rows` growing by one, so the net change is 0
|
|
40
|
+
* rather than -1.
|
|
41
|
+
*/
|
|
42
|
+
export function viewportRows(rows) {
|
|
43
|
+
if (!Number.isFinite(rows) || rows < MIN_FULLSCREEN_ROWS)
|
|
44
|
+
return 0;
|
|
45
|
+
const c = chromeBudget(rows);
|
|
46
|
+
return Math.max(0, frameHeight(rows) - c.header - c.toast - c.composer - c.status);
|
|
47
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame geometry + render-mode decision (spec §4.1 / §4.2).
|
|
3
|
+
*
|
|
4
|
+
* Two pure functions with zero React dependency so the load-bearing invariant
|
|
5
|
+
* I-1 (`frameHeight(r) < r`) can be pinned by a unit test rather than by hope.
|
|
6
|
+
*/
|
|
7
|
+
export type RenderMode = 'fullscreen' | 'inline';
|
|
8
|
+
/** Below this many rows a full-screen frame has no usable viewport left. */
|
|
9
|
+
export declare const MIN_FULLSCREEN_ROWS = 12;
|
|
10
|
+
/** Below this many columns the chrome (composer + status bar) cannot line-wrap sanely. */
|
|
11
|
+
export declare const MIN_FULLSCREEN_COLS = 40;
|
|
12
|
+
/** Used when `stdout.rows` is missing (pipes, test stubs, some PTYs). */
|
|
13
|
+
export declare const FALLBACK_ROWS = 24;
|
|
14
|
+
/** Used when `stdout.columns` is missing. */
|
|
15
|
+
export declare const FALLBACK_COLS = 80;
|
|
16
|
+
/**
|
|
17
|
+
* Frame height. MUST stay strictly below `rows`:
|
|
18
|
+
* - `>= rows` hits ink.js:121, which drops incremental `eraseLines` updates and
|
|
19
|
+
* writes `clearTerminal + <all Static history> + output` on EVERY frame —
|
|
20
|
+
* 30×/s under a 33 ms streaming window, with write amplification that grows
|
|
21
|
+
* monotonically with the session;
|
|
22
|
+
* - `== rows` lets the trailing '\n' log-update appends scroll the buffer by one
|
|
23
|
+
* line, so `eraseLines(previousLineCount)` drifts and the frame slowly eats
|
|
24
|
+
* the content above it.
|
|
25
|
+
* `frame.test.ts` asserts `frameHeight(r) < r` for every r in [1, 200].
|
|
26
|
+
*
|
|
27
|
+
* The lower clamp is 0 — NEVER `MIN_FULLSCREEN_ROWS - 1` (returns 11 for
|
|
28
|
+
* rows ∈ [1, 11], i.e. `>= rows`: precisely the clearTerminal disaster this
|
|
29
|
+
* function exists to prevent) and NEVER 1 either, because `Math.max(1, r - 1)`
|
|
30
|
+
* returns 1 for r = 1 and `1 >= 1` violates the invariant all the same. That
|
|
31
|
+
* range IS reachable: §4.11 keeps the alt-screen and renders a "terminal too
|
|
32
|
+
* small" placeholder when a resize drops below MIN_FULLSCREEN_ROWS, so a user
|
|
33
|
+
* dragging their window down lands in it. The placeholder takes its height from
|
|
34
|
+
* this same function. A 0-height frame renders nothing, which on a 1-row
|
|
35
|
+
* terminal is strictly better than a per-frame full-screen repaint.
|
|
36
|
+
*/
|
|
37
|
+
export declare function frameHeight(rows: number | undefined): number;
|
|
38
|
+
interface StdoutLike {
|
|
39
|
+
isTTY?: boolean;
|
|
40
|
+
rows?: number;
|
|
41
|
+
columns?: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Decide the render mode once, at startup (never re-decided at run time —
|
|
45
|
+
* entering/leaving the alt-screen mid-session is a state machine we do not want).
|
|
46
|
+
*
|
|
47
|
+
* Order is semantics, not style:
|
|
48
|
+
* 1. an explicit opt-out always wins (users can always get v0.2.0 back);
|
|
49
|
+
* 2. `isTTY` is the one gate `--fullscreen` cannot override — writing ANSI into
|
|
50
|
+
* a pipe or a redirected file is a contract violation, whereas a heuristic
|
|
51
|
+
* misfire only costs polish;
|
|
52
|
+
* 3. the explicit force comes next so it can beat the heuristics below;
|
|
53
|
+
* 4. heuristics (TERM/CI/size), all overridable by `--fullscreen`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function decideRenderMode(flags: {
|
|
56
|
+
fullscreen?: boolean;
|
|
57
|
+
}, env: Record<string, string | undefined>, stdout: StdoutLike | undefined): RenderMode;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame geometry + render-mode decision (spec §4.1 / §4.2).
|
|
3
|
+
*
|
|
4
|
+
* Two pure functions with zero React dependency so the load-bearing invariant
|
|
5
|
+
* I-1 (`frameHeight(r) < r`) can be pinned by a unit test rather than by hope.
|
|
6
|
+
*/
|
|
7
|
+
/** Below this many rows a full-screen frame has no usable viewport left. */
|
|
8
|
+
export const MIN_FULLSCREEN_ROWS = 12;
|
|
9
|
+
/** Below this many columns the chrome (composer + status bar) cannot line-wrap sanely. */
|
|
10
|
+
export const MIN_FULLSCREEN_COLS = 40;
|
|
11
|
+
/** Used when `stdout.rows` is missing (pipes, test stubs, some PTYs). */
|
|
12
|
+
export const FALLBACK_ROWS = 24;
|
|
13
|
+
/** Used when `stdout.columns` is missing. */
|
|
14
|
+
export const FALLBACK_COLS = 80;
|
|
15
|
+
/**
|
|
16
|
+
* Frame height. MUST stay strictly below `rows`:
|
|
17
|
+
* - `>= rows` hits ink.js:121, which drops incremental `eraseLines` updates and
|
|
18
|
+
* writes `clearTerminal + <all Static history> + output` on EVERY frame —
|
|
19
|
+
* 30×/s under a 33 ms streaming window, with write amplification that grows
|
|
20
|
+
* monotonically with the session;
|
|
21
|
+
* - `== rows` lets the trailing '\n' log-update appends scroll the buffer by one
|
|
22
|
+
* line, so `eraseLines(previousLineCount)` drifts and the frame slowly eats
|
|
23
|
+
* the content above it.
|
|
24
|
+
* `frame.test.ts` asserts `frameHeight(r) < r` for every r in [1, 200].
|
|
25
|
+
*
|
|
26
|
+
* The lower clamp is 0 — NEVER `MIN_FULLSCREEN_ROWS - 1` (returns 11 for
|
|
27
|
+
* rows ∈ [1, 11], i.e. `>= rows`: precisely the clearTerminal disaster this
|
|
28
|
+
* function exists to prevent) and NEVER 1 either, because `Math.max(1, r - 1)`
|
|
29
|
+
* returns 1 for r = 1 and `1 >= 1` violates the invariant all the same. That
|
|
30
|
+
* range IS reachable: §4.11 keeps the alt-screen and renders a "terminal too
|
|
31
|
+
* small" placeholder when a resize drops below MIN_FULLSCREEN_ROWS, so a user
|
|
32
|
+
* dragging their window down lands in it. The placeholder takes its height from
|
|
33
|
+
* this same function. A 0-height frame renders nothing, which on a 1-row
|
|
34
|
+
* terminal is strictly better than a per-frame full-screen repaint.
|
|
35
|
+
*/
|
|
36
|
+
export function frameHeight(rows) {
|
|
37
|
+
const r = rows && rows > 0 ? rows : FALLBACK_ROWS;
|
|
38
|
+
return Math.max(0, r - 1);
|
|
39
|
+
}
|
|
40
|
+
/** `CI=` / `CI=0` / `CI=false` are not CI; anything else non-empty is. */
|
|
41
|
+
function isTruthyEnv(value) {
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
return false;
|
|
44
|
+
const v = value.trim().toLowerCase();
|
|
45
|
+
return v.length > 0 && v !== '0' && v !== 'false';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Decide the render mode once, at startup (never re-decided at run time —
|
|
49
|
+
* entering/leaving the alt-screen mid-session is a state machine we do not want).
|
|
50
|
+
*
|
|
51
|
+
* Order is semantics, not style:
|
|
52
|
+
* 1. an explicit opt-out always wins (users can always get v0.2.0 back);
|
|
53
|
+
* 2. `isTTY` is the one gate `--fullscreen` cannot override — writing ANSI into
|
|
54
|
+
* a pipe or a redirected file is a contract violation, whereas a heuristic
|
|
55
|
+
* misfire only costs polish;
|
|
56
|
+
* 3. the explicit force comes next so it can beat the heuristics below;
|
|
57
|
+
* 4. heuristics (TERM/CI/size), all overridable by `--fullscreen`.
|
|
58
|
+
*/
|
|
59
|
+
export function decideRenderMode(flags, env, stdout) {
|
|
60
|
+
if (flags.fullscreen === false)
|
|
61
|
+
return 'inline';
|
|
62
|
+
if (!stdout?.isTTY)
|
|
63
|
+
return 'inline';
|
|
64
|
+
if (flags.fullscreen === true)
|
|
65
|
+
return 'fullscreen';
|
|
66
|
+
if (env.TERM === 'dumb')
|
|
67
|
+
return 'inline';
|
|
68
|
+
if (isTruthyEnv(env.CI))
|
|
69
|
+
return 'inline';
|
|
70
|
+
if ((stdout.rows ?? 0) < MIN_FULLSCREEN_ROWS)
|
|
71
|
+
return 'inline';
|
|
72
|
+
if ((stdout.columns ?? 0) < MIN_FULLSCREEN_COLS)
|
|
73
|
+
return 'inline';
|
|
74
|
+
return 'fullscreen';
|
|
75
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay windowing (spec §4.4) — pure arithmetic for `OverlayFrame`.
|
|
3
|
+
*
|
|
4
|
+
* Overlays used to be dropped into `<Box overflow="hidden">` with no scrolling
|
|
5
|
+
* and no indication that anything had been cut: `/help` needs 29 rows, a 30-row
|
|
6
|
+
* terminal gave it 15, and everything from `/clear` downward was simply invisible.
|
|
7
|
+
*
|
|
8
|
+
* This does NOT reuse `ScrollViewport`. That component owns its offset in its own
|
|
9
|
+
* state as a deliberate single-source-of-truth decision, so routing an overlay
|
|
10
|
+
* through the same instance would leave the transcript parked at the overlay's
|
|
11
|
+
* offset after closing. Overlay content is finite and already in memory, so
|
|
12
|
+
* slicing an array is both cheaper and free of a second `measureElement` loop.
|
|
13
|
+
*/
|
|
14
|
+
export interface OverlayWindow {
|
|
15
|
+
/** First visible index. */
|
|
16
|
+
start: number;
|
|
17
|
+
/** How many rows are visible. */
|
|
18
|
+
visible: number;
|
|
19
|
+
total: number;
|
|
20
|
+
/** The requested offset after clamping — feed this back to keep state sane. */
|
|
21
|
+
clamped: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Slice `total` single-row items to a `height`-row window at `offset`.
|
|
25
|
+
*
|
|
26
|
+
* Guarantees for every input, including negative / absurd offsets and
|
|
27
|
+
* non-finite heights: `0 <= start` and `start + visible <= total` (A-2).
|
|
28
|
+
*/
|
|
29
|
+
export declare function sliceWindow(total: number, offset: number, height: number): OverlayWindow;
|
|
30
|
+
/**
|
|
31
|
+
* Visible-row budget for a self-managed (mode B) overlay list — i.e. the `limit`
|
|
32
|
+
* handed to `ink-select-input` in the model picker.
|
|
33
|
+
*
|
|
34
|
+
* A pure function rather than an inline expression because the test stack only
|
|
35
|
+
* returns rendered strings: there is no way to read a child's props back, so the
|
|
36
|
+
* only way to pin this behaviour (A-11) is to test the arithmetic directly.
|
|
37
|
+
*
|
|
38
|
+
* The 5 rows subtracted are the frame's own chrome: title, footer, the top and
|
|
39
|
+
* bottom border, and the frame's `marginTop` — which is spent out of the same
|
|
40
|
+
* budget, so leaving it out pushes the bottom border past the viewport where
|
|
41
|
+
* `overflow: hidden` silently eats it.
|
|
42
|
+
*/
|
|
43
|
+
export declare function overlayListLimit(maxRows: number): number;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay windowing (spec §4.4) — pure arithmetic for `OverlayFrame`.
|
|
3
|
+
*
|
|
4
|
+
* Overlays used to be dropped into `<Box overflow="hidden">` with no scrolling
|
|
5
|
+
* and no indication that anything had been cut: `/help` needs 29 rows, a 30-row
|
|
6
|
+
* terminal gave it 15, and everything from `/clear` downward was simply invisible.
|
|
7
|
+
*
|
|
8
|
+
* This does NOT reuse `ScrollViewport`. That component owns its offset in its own
|
|
9
|
+
* state as a deliberate single-source-of-truth decision, so routing an overlay
|
|
10
|
+
* through the same instance would leave the transcript parked at the overlay's
|
|
11
|
+
* offset after closing. Overlay content is finite and already in memory, so
|
|
12
|
+
* slicing an array is both cheaper and free of a second `measureElement` loop.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Slice `total` single-row items to a `height`-row window at `offset`.
|
|
16
|
+
*
|
|
17
|
+
* Guarantees for every input, including negative / absurd offsets and
|
|
18
|
+
* non-finite heights: `0 <= start` and `start + visible <= total` (A-2).
|
|
19
|
+
*/
|
|
20
|
+
export function sliceWindow(total, offset, height) {
|
|
21
|
+
const t = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0;
|
|
22
|
+
const h = Number.isFinite(height) ? Math.max(0, Math.floor(height)) : t;
|
|
23
|
+
const visible = Math.min(t, h);
|
|
24
|
+
const maxOffset = Math.max(0, t - visible);
|
|
25
|
+
const raw = Number.isFinite(offset) ? Math.floor(offset) : 0;
|
|
26
|
+
const clamped = Math.min(maxOffset, Math.max(0, raw));
|
|
27
|
+
return { start: clamped, visible, total: t, clamped };
|
|
28
|
+
}
|
|
29
|
+
/** Smallest list window worth showing before scrolling stops helping. */
|
|
30
|
+
const MIN_LIST_LIMIT = 3;
|
|
31
|
+
/** Beyond this a picker is a scroll problem, not a list problem. */
|
|
32
|
+
const MAX_LIST_LIMIT = 20;
|
|
33
|
+
/**
|
|
34
|
+
* Visible-row budget for a self-managed (mode B) overlay list — i.e. the `limit`
|
|
35
|
+
* handed to `ink-select-input` in the model picker.
|
|
36
|
+
*
|
|
37
|
+
* A pure function rather than an inline expression because the test stack only
|
|
38
|
+
* returns rendered strings: there is no way to read a child's props back, so the
|
|
39
|
+
* only way to pin this behaviour (A-11) is to test the arithmetic directly.
|
|
40
|
+
*
|
|
41
|
+
* The 5 rows subtracted are the frame's own chrome: title, footer, the top and
|
|
42
|
+
* bottom border, and the frame's `marginTop` — which is spent out of the same
|
|
43
|
+
* budget, so leaving it out pushes the bottom border past the viewport where
|
|
44
|
+
* `overflow: hidden` silently eats it.
|
|
45
|
+
*/
|
|
46
|
+
export function overlayListLimit(maxRows) {
|
|
47
|
+
if (!Number.isFinite(maxRows))
|
|
48
|
+
return MAX_LIST_LIMIT;
|
|
49
|
+
const usable = Math.floor(maxRows) - 5;
|
|
50
|
+
return Math.min(MAX_LIST_LIMIT, Math.max(MIN_LIST_LIMIT, usable));
|
|
51
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll maths for the self-drawn viewport (spec §4.5). Pure, React-free.
|
|
3
|
+
*
|
|
4
|
+
* `offset` counts LINES FROM THE BOTTOM — equivalently, the number of rows
|
|
5
|
+
* currently hidden BELOW the viewport. `0` means pinned to the newest output;
|
|
6
|
+
* `overflowLines` means parked at the very top. Counting from the bottom is what
|
|
7
|
+
* makes auto-follow free: when new content arrives `overflowLines` grows while
|
|
8
|
+
* `offset` stays 0, so the viewport keeps showing the tail without anyone
|
|
9
|
+
* calling a "scroll to bottom" routine.
|
|
10
|
+
*
|
|
11
|
+
* The rows hidden ABOVE are therefore `overflowLines - offset`. Mixing the two
|
|
12
|
+
* up inverts both the bottom-snap test and the "N new lines" hint.
|
|
13
|
+
*/
|
|
14
|
+
export interface ScrollState {
|
|
15
|
+
offset: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ViewportMetrics {
|
|
18
|
+
/** Visible height of the clipping box, in rows. */
|
|
19
|
+
viewport: number;
|
|
20
|
+
/** Natural (unclipped) height of the content, in rows. */
|
|
21
|
+
content: number;
|
|
22
|
+
}
|
|
23
|
+
export type ScrollIntent = 'lineUp' | 'lineDown' | 'pageUp' | 'pageDown' | 'toTop' | 'toBottom';
|
|
24
|
+
export declare const BOTTOM_SNAP_TOLERANCE = 2;
|
|
25
|
+
export declare function clampScroll(offset: number, overflowLines: number): number;
|
|
26
|
+
export declare function pageSize(viewportHeight: number): number;
|
|
27
|
+
export declare function isPinnedToBottom(state: ScrollState): boolean;
|
|
28
|
+
export declare function applyScroll(state: ScrollState, intent: ScrollIntent, metrics: ViewportMetrics): ScrollState;
|