@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.
Files changed (156) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +558 -0
  3. package/dist/agent/coalesce.d.ts +14 -0
  4. package/dist/agent/coalesce.js +41 -0
  5. package/dist/agent/controller.d.ts +132 -0
  6. package/dist/agent/controller.js +332 -0
  7. package/dist/agent/headless.d.ts +22 -0
  8. package/dist/agent/headless.js +74 -0
  9. package/dist/agent/reducer.d.ts +181 -0
  10. package/dist/agent/reducer.js +420 -0
  11. package/dist/agent/system-prompt.d.ts +30 -0
  12. package/dist/agent/system-prompt.js +58 -0
  13. package/dist/agent/usage.d.ts +13 -0
  14. package/dist/agent/usage.js +41 -0
  15. package/dist/cli.d.ts +11 -0
  16. package/dist/cli.js +482 -0
  17. package/dist/commands/builtins.d.ts +16 -0
  18. package/dist/commands/builtins.js +223 -0
  19. package/dist/commands/registry.d.ts +48 -0
  20. package/dist/commands/registry.js +45 -0
  21. package/dist/commands/skills.d.ts +22 -0
  22. package/dist/commands/skills.js +625 -0
  23. package/dist/config/app-paths.d.ts +11 -0
  24. package/dist/config/app-paths.js +16 -0
  25. package/dist/config/env.d.ts +28 -0
  26. package/dist/config/env.js +92 -0
  27. package/dist/config/load.d.ts +48 -0
  28. package/dist/config/load.js +158 -0
  29. package/dist/config/migrate-legacy-state.d.ts +78 -0
  30. package/dist/config/migrate-legacy-state.js +181 -0
  31. package/dist/config/schema.d.ts +249 -0
  32. package/dist/config/schema.js +247 -0
  33. package/dist/config/store.d.ts +44 -0
  34. package/dist/config/store.js +126 -0
  35. package/dist/input/keymap.d.ts +20 -0
  36. package/dist/input/keymap.js +85 -0
  37. package/dist/session/persist.d.ts +24 -0
  38. package/dist/session/persist.js +51 -0
  39. package/dist/skills/archive.d.ts +41 -0
  40. package/dist/skills/archive.js +152 -0
  41. package/dist/skills/cli-commands.d.ts +52 -0
  42. package/dist/skills/cli-commands.js +502 -0
  43. package/dist/skills/fetch-source.d.ts +104 -0
  44. package/dist/skills/fetch-source.js +448 -0
  45. package/dist/skills/installer.d.ts +117 -0
  46. package/dist/skills/installer.js +414 -0
  47. package/dist/skills/lock.d.ts +75 -0
  48. package/dist/skills/lock.js +223 -0
  49. package/dist/skills/manifest.d.ts +47 -0
  50. package/dist/skills/manifest.js +95 -0
  51. package/dist/skills/node-host.d.ts +23 -0
  52. package/dist/skills/node-host.js +59 -0
  53. package/dist/skills/paths.d.ts +61 -0
  54. package/dist/skills/paths.js +107 -0
  55. package/dist/skills/service.d.ts +256 -0
  56. package/dist/skills/service.js +730 -0
  57. package/dist/skills/tools.d.ts +35 -0
  58. package/dist/skills/tools.js +164 -0
  59. package/dist/skills/updater.d.ts +101 -0
  60. package/dist/skills/updater.js +330 -0
  61. package/dist/skills/usage.d.ts +72 -0
  62. package/dist/skills/usage.js +216 -0
  63. package/dist/tools/bash-tool.d.ts +12 -0
  64. package/dist/tools/bash-tool.js +131 -0
  65. package/dist/tools/diff.d.ts +15 -0
  66. package/dist/tools/diff.js +50 -0
  67. package/dist/tools/fs-tools.d.ts +16 -0
  68. package/dist/tools/fs-tools.js +215 -0
  69. package/dist/tools/index.d.ts +93 -0
  70. package/dist/tools/index.js +157 -0
  71. package/dist/tools/search-tools.d.ts +11 -0
  72. package/dist/tools/search-tools.js +225 -0
  73. package/dist/ui/App.d.ts +24 -0
  74. package/dist/ui/App.js +522 -0
  75. package/dist/ui/AutocompletePopup.d.ts +21 -0
  76. package/dist/ui/AutocompletePopup.js +19 -0
  77. package/dist/ui/Composer.d.ts +40 -0
  78. package/dist/ui/Composer.js +61 -0
  79. package/dist/ui/Header.d.ts +30 -0
  80. package/dist/ui/Header.js +25 -0
  81. package/dist/ui/Logo.d.ts +50 -0
  82. package/dist/ui/Logo.js +85 -0
  83. package/dist/ui/Markdown.d.ts +23 -0
  84. package/dist/ui/Markdown.js +122 -0
  85. package/dist/ui/PromptInput.d.ts +81 -0
  86. package/dist/ui/PromptInput.js +383 -0
  87. package/dist/ui/SessionOpener.d.ts +30 -0
  88. package/dist/ui/SessionOpener.js +12 -0
  89. package/dist/ui/StatusBar.d.ts +39 -0
  90. package/dist/ui/StatusBar.js +43 -0
  91. package/dist/ui/ToastStack.d.ts +24 -0
  92. package/dist/ui/ToastStack.js +38 -0
  93. package/dist/ui/Transcript.d.ts +62 -0
  94. package/dist/ui/Transcript.js +128 -0
  95. package/dist/ui/capabilities.d.ts +25 -0
  96. package/dist/ui/capabilities.js +59 -0
  97. package/dist/ui/console-bridge.d.ts +19 -0
  98. package/dist/ui/console-bridge.js +52 -0
  99. package/dist/ui/density.d.ts +26 -0
  100. package/dist/ui/density.js +34 -0
  101. package/dist/ui/entries/AssistantEntry.d.ts +23 -0
  102. package/dist/ui/entries/AssistantEntry.js +13 -0
  103. package/dist/ui/entries/EntryFrame.d.ts +23 -0
  104. package/dist/ui/entries/EntryFrame.js +6 -0
  105. package/dist/ui/entries/ToolCard.d.ts +38 -0
  106. package/dist/ui/entries/ToolCard.js +72 -0
  107. package/dist/ui/entries/ToolPreview.d.ts +57 -0
  108. package/dist/ui/entries/ToolPreview.js +144 -0
  109. package/dist/ui/entries/UserEntry.d.ts +14 -0
  110. package/dist/ui/entries/UserEntry.js +5 -0
  111. package/dist/ui/exit-snapshot.d.ts +27 -0
  112. package/dist/ui/exit-snapshot.js +26 -0
  113. package/dist/ui/gauge.d.ts +24 -0
  114. package/dist/ui/gauge.js +36 -0
  115. package/dist/ui/glyphs.d.ts +87 -0
  116. package/dist/ui/glyphs.js +127 -0
  117. package/dist/ui/gradient.d.ts +11 -0
  118. package/dist/ui/gradient.js +50 -0
  119. package/dist/ui/layout/AppShell.d.ts +24 -0
  120. package/dist/ui/layout/AppShell.js +9 -0
  121. package/dist/ui/layout/Gutter.d.ts +67 -0
  122. package/dist/ui/layout/Gutter.js +42 -0
  123. package/dist/ui/layout/OverlayFrame.d.ts +62 -0
  124. package/dist/ui/layout/OverlayFrame.js +92 -0
  125. package/dist/ui/layout/ScrollViewport.d.ts +33 -0
  126. package/dist/ui/layout/ScrollViewport.js +71 -0
  127. package/dist/ui/layout/budget.d.ts +42 -0
  128. package/dist/ui/layout/budget.js +47 -0
  129. package/dist/ui/layout/frame.d.ts +58 -0
  130. package/dist/ui/layout/frame.js +75 -0
  131. package/dist/ui/layout/overlay-window.d.ts +43 -0
  132. package/dist/ui/layout/overlay-window.js +51 -0
  133. package/dist/ui/layout/scroll.d.ts +28 -0
  134. package/dist/ui/layout/scroll.js +76 -0
  135. package/dist/ui/layout/useTerminalSize.d.ts +13 -0
  136. package/dist/ui/layout/useTerminalSize.js +50 -0
  137. package/dist/ui/markdown-blocks.d.ts +62 -0
  138. package/dist/ui/markdown-blocks.js +120 -0
  139. package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
  140. package/dist/ui/overlays/ConfirmDialog.js +25 -0
  141. package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
  142. package/dist/ui/overlays/HelpOverlay.js +60 -0
  143. package/dist/ui/overlays/ModelPicker.d.ts +29 -0
  144. package/dist/ui/overlays/ModelPicker.js +29 -0
  145. package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
  146. package/dist/ui/overlays/SettingsScreen.js +106 -0
  147. package/dist/ui/palettes.d.ts +79 -0
  148. package/dist/ui/palettes.js +115 -0
  149. package/dist/ui/screen.d.ts +27 -0
  150. package/dist/ui/screen.js +57 -0
  151. package/dist/ui/theme.d.ts +78 -0
  152. package/dist/ui/theme.js +127 -0
  153. package/dist/ui/transcript-text.d.ts +27 -0
  154. package/dist/ui/transcript-text.js +110 -0
  155. package/package.json +73 -0
  156. 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;