@aragon-agent/cli 0.5.0 → 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.
Files changed (102) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +254 -7
  3. package/dist/agent/agent-mode.d.ts +53 -0
  4. package/dist/agent/agent-mode.js +69 -0
  5. package/dist/agent/controller.d.ts +69 -0
  6. package/dist/agent/controller.js +154 -1
  7. package/dist/agent/plan-prompt.d.ts +18 -0
  8. package/dist/agent/plan-prompt.js +93 -0
  9. package/dist/agent/reducer.d.ts +14 -1
  10. package/dist/agent/reducer.js +9 -0
  11. package/dist/agent/system-prompt.d.ts +15 -0
  12. package/dist/agent/system-prompt.js +8 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +247 -23
  15. package/dist/commands/builtins.js +60 -0
  16. package/dist/commands/registry.d.ts +17 -0
  17. package/dist/config/app-paths.d.ts +71 -4
  18. package/dist/config/app-paths.js +139 -8
  19. package/dist/config/cli-commands.d.ts +43 -0
  20. package/dist/config/cli-commands.js +196 -0
  21. package/dist/config/env.d.ts +7 -1
  22. package/dist/config/env.js +53 -1
  23. package/dist/config/load.d.ts +10 -0
  24. package/dist/config/load.js +82 -2
  25. package/dist/config/migrate-home.d.ts +63 -0
  26. package/dist/config/migrate-home.js +165 -0
  27. package/dist/config/migrate-legacy-state.d.ts +10 -0
  28. package/dist/config/migrate-legacy-state.js +15 -6
  29. package/dist/config/schema.d.ts +81 -0
  30. package/dist/config/schema.js +85 -0
  31. package/dist/config/store.d.ts +30 -12
  32. package/dist/config/store.js +47 -35
  33. package/dist/input/mouse-events.d.ts +61 -0
  34. package/dist/input/mouse-events.js +153 -0
  35. package/dist/input/stdin-mouse-filter.d.ts +42 -0
  36. package/dist/input/stdin-mouse-filter.js +153 -0
  37. package/dist/logging/cli-commands.d.ts +25 -0
  38. package/dist/logging/cli-commands.js +221 -0
  39. package/dist/logging/file-sink.d.ts +114 -0
  40. package/dist/logging/file-sink.js +326 -0
  41. package/dist/logging/install.d.ts +163 -0
  42. package/dist/logging/install.js +302 -0
  43. package/dist/logging/levels.d.ts +40 -0
  44. package/dist/logging/levels.js +59 -0
  45. package/dist/logging/logger.d.ts +110 -0
  46. package/dist/logging/logger.js +237 -0
  47. package/dist/logging/redact.d.ts +28 -0
  48. package/dist/logging/redact.js +90 -0
  49. package/dist/logging/secret-registry.d.ts +30 -0
  50. package/dist/logging/secret-registry.js +55 -0
  51. package/dist/skills/paths.d.ts +2 -4
  52. package/dist/skills/paths.js +7 -9
  53. package/dist/tools/human-input.d.ts +179 -0
  54. package/dist/tools/human-input.js +227 -0
  55. package/dist/tools/index.d.ts +35 -3
  56. package/dist/tools/index.js +96 -9
  57. package/dist/tools/plan-tools.d.ts +64 -0
  58. package/dist/tools/plan-tools.js +198 -0
  59. package/dist/ui/App.d.ts +12 -1
  60. package/dist/ui/App.js +223 -17
  61. package/dist/ui/Composer.d.ts +9 -1
  62. package/dist/ui/Composer.js +26 -7
  63. package/dist/ui/ModeChip.d.ts +29 -0
  64. package/dist/ui/ModeChip.js +15 -0
  65. package/dist/ui/PromptInput.d.ts +22 -1
  66. package/dist/ui/PromptInput.js +49 -2
  67. package/dist/ui/StatusBar.d.ts +15 -0
  68. package/dist/ui/StatusBar.js +10 -2
  69. package/dist/ui/entries/ToolPreview.d.ts +2 -0
  70. package/dist/ui/entries/ToolPreview.js +64 -0
  71. package/dist/ui/glyphs.d.ts +21 -0
  72. package/dist/ui/glyphs.js +12 -0
  73. package/dist/ui/layout/AppShell.d.ts +10 -1
  74. package/dist/ui/layout/AppShell.js +38 -3
  75. package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
  76. package/dist/ui/layout/ScrollIndicator.js +15 -0
  77. package/dist/ui/layout/ScrollViewport.d.ts +8 -2
  78. package/dist/ui/layout/ScrollViewport.js +24 -4
  79. package/dist/ui/layout/regions.d.ts +55 -0
  80. package/dist/ui/layout/regions.js +52 -0
  81. package/dist/ui/layout/scroll-indicator.d.ts +27 -0
  82. package/dist/ui/layout/scroll-indicator.js +35 -0
  83. package/dist/ui/layout/scroll.d.ts +9 -0
  84. package/dist/ui/layout/scroll.js +23 -0
  85. package/dist/ui/layout/wrap-rows.d.ts +24 -0
  86. package/dist/ui/layout/wrap-rows.js +74 -0
  87. package/dist/ui/overlays/HelpOverlay.js +15 -0
  88. package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
  89. package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
  90. package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
  91. package/dist/ui/overlays/QuestionOverlay.js +193 -0
  92. package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
  93. package/dist/ui/overlays/SettingsScreen.js +8 -1
  94. package/dist/ui/screen.d.ts +14 -1
  95. package/dist/ui/screen.js +37 -3
  96. package/dist/ui/use-startup-notices.d.ts +25 -0
  97. package/dist/ui/use-startup-notices.js +64 -0
  98. package/dist/ui/use-wheel-routing.d.ts +68 -0
  99. package/dist/ui/use-wheel-routing.js +139 -0
  100. package/package.json +3 -2
  101. package/runtime/insecure-tls-warning.cjs +62 -0
  102. package/runtime/insecure-tls-warning.d.cts +5 -0
@@ -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
- /** Scroll intent. `nonce` is monotonic so pressing the same key twice re-fires. */
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;
@@ -16,10 +16,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
16
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
17
17
  import { Box, Text, measureElement, useStdout } from 'ink';
18
18
  import { pickGlyphs } from '../glyphs.js';
19
- import { applyScroll, clampScroll, } from './scroll.js';
19
+ import { applyScrollTimes, clampScroll, } from './scroll.js';
20
+ import { thumbRange } from './scroll-indicator.js';
21
+ import { MIN_INDICATOR_COLS, ScrollIndicator } from './ScrollIndicator.js';
20
22
  /** Below this the full hint sentence wraps and eats a second viewport row. */
21
23
  const TERSE_HINT_COLS = 42;
22
- export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesChange, theme, caps, children, }) {
24
+ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesChange, showScrollIndicator = false, theme, caps, children, }) {
23
25
  const { stdout } = useStdout();
24
26
  const cols = stdout?.columns ?? 80;
25
27
  const glyphs = pickGlyphs(caps);
@@ -38,12 +40,26 @@ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesCh
38
40
  const content = innerRef.current ? measureElement(innerRef.current).height : 0;
39
41
  setMetrics((prev) => (prev.viewport === viewport && prev.content === content ? prev : { viewport, content }));
40
42
  });
43
+ // I-9: seeded from the INCOMING nonce during the first render, so the effect
44
+ // below cannot apply an intent on the mount that first observes it. A
45
+ // `useEffect([nonce])` fires on mount as well as on change, and this
46
+ // component is unmounted whenever an overlay opens (`App.tsx` swaps it out) —
47
+ // without the guard, closing an overlay replays whatever wheel gesture was in
48
+ // flight when it opened, and the transcript jumps a page on its own. That is
49
+ // R-P1-7, which this codebase has already paid for once through the keyboard
50
+ // path; the overlay-swallowing rule in `App`'s key handler becomes a SECOND
51
+ // line of defence rather than the only one.
52
+ const lastAppliedNonce = useRef(intent?.nonce);
41
53
  const intentNonce = intent?.nonce ?? 0;
42
54
  useEffect(() => {
43
55
  const current = intentRef.current;
44
56
  if (!current)
45
57
  return;
46
- setScroll((prev) => applyScroll(prev, current.kind, metricsRef.current));
58
+ if (current.nonce === lastAppliedNonce.current)
59
+ return;
60
+ lastAppliedNonce.current = current.nonce;
61
+ const repeat = current.repeat ?? 1;
62
+ setScroll((prev) => applyScrollTimes(prev, current.kind, metricsRef.current, repeat));
47
63
  // Keyed on the nonce alone: `intent` is a fresh object on every parent
48
64
  // render, so depending on it directly would re-scroll on every keystroke.
49
65
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -61,7 +77,11 @@ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesCh
61
77
  // what "N new lines you have not scrolled down to" means.
62
78
  const hiddenBelow = offset;
63
79
  const showHint = hiddenBelow > 0;
64
- 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 && (
80
+ // Suppressed below `MIN_INDICATOR_COLS`, where a column of content is worth
81
+ // more than the affordance. The routing never reads this flag, which is what
82
+ // keeps the indicator independently revertible (§13-Q3).
83
+ const indicatorVisible = showScrollIndicator && cols >= MIN_INDICATOR_COLS;
84
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Box, { ref: clipRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Box, { ref: innerRef, flexDirection: "column", flexShrink: 0, marginTop: -shiftUp, children: children }) }), indicatorVisible && (_jsx(ScrollIndicator, { rows: metrics.viewport, thumb: thumbRange(metrics.viewport, metrics.content, offset), theme: theme, caps: caps }))] }), showHint && (
65
85
  // Not an overlay — Ink has no absolute positioning or z-index. This takes
66
86
  // the viewport's last row, trading one line of content for a hint that
67
87
  // cannot drift. Cheaper than stacking with negative margins.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Wheel band hit-testing (mouse-wheel-region-routing §4.5) — pure, React-free.
3
+ *
4
+ * The requirement is stated in terms of REGIONS ("scrolling over the agent area
5
+ * scrolls the transcript; scrolling over the input box steps prompt history"),
6
+ * so the router needs to turn a reported terminal row into one of two bands.
7
+ *
8
+ * The boundary is MEASURED, never computed from `budget.ts`. That arithmetic
9
+ * describes the PLANNED chrome, but the real bottom chrome grows and shrinks at
10
+ * run time: the toast row comes and goes, the hint row is dropped below
11
+ * `HINT_MIN_ROWS`, and the autocomplete popup adds up to nine rows. Routing off
12
+ * the plan would put the boundary in the wrong place exactly when the popup is
13
+ * open — the moment precision matters most (D-5 / D-16).
14
+ */
15
+ export type WheelBand = 'content' | 'composer';
16
+ export interface FrameGeometry {
17
+ /**
18
+ * 1-based row of the frame's first row. 1 in the alternate screen.
19
+ *
20
+ * This is DERIVED, not assumed, and it has a named dependency: it holds
21
+ * because the full-screen transcript renders no `<Static>`
22
+ * (`Transcript.tsx`), so Ink's `log-update` writes `frameHeight(rows) = rows-1`
23
+ * lines plus a newline into a `rows`-row buffer — nothing scrolls, and the
24
+ * frame sits at rows `1 … rows-1` with the cursor parked on the undrawn last
25
+ * row. Reintroducing `<Static>` into the full-screen path pushes the frame
26
+ * down the screen and silently biases every hit test; whoever does that has
27
+ * to change `top` here at the same time.
28
+ */
29
+ top: number;
30
+ /** Rows the frame occupies — `frameHeight(rows)`. */
31
+ height: number;
32
+ /** Measured rows of the bottom chrome (toast + composer + status). */
33
+ bottom: number;
34
+ }
35
+ /**
36
+ * The "I do not know where the frame is" value.
37
+ *
38
+ * `App` writes it into the geometry ref on every render path that does NOT
39
+ * render the full-screen `AppShell` — the terminal-too-small placeholder and
40
+ * inline mode — so a stale measurement can never outlive the frame it
41
+ * described. There is deliberately no arithmetic fallback: an unmeasured frame
42
+ * routes to `'content'`, which is the band that cannot destroy anything, and
43
+ * one invisible frame after a resize is a better failure than a wrong band
44
+ * (§4.5, P1-5 / D-16).
45
+ */
46
+ export declare const UNMEASURED_GEOMETRY: FrameGeometry;
47
+ /**
48
+ * Which band does terminal row `y` belong to?
49
+ *
50
+ * Fails toward `'content'`: the whole point of this feature is that a wheel
51
+ * notch must never edit the user's draft (I-3), so every degenerate input —
52
+ * nothing measured yet, a row outside the frame, a chrome measurement that
53
+ * swallowed the screen — resolves to the harmless direction.
54
+ */
55
+ export declare function hitTestWheel(y: number, geometry: FrameGeometry): WheelBand;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Wheel band hit-testing (mouse-wheel-region-routing §4.5) — pure, React-free.
3
+ *
4
+ * The requirement is stated in terms of REGIONS ("scrolling over the agent area
5
+ * scrolls the transcript; scrolling over the input box steps prompt history"),
6
+ * so the router needs to turn a reported terminal row into one of two bands.
7
+ *
8
+ * The boundary is MEASURED, never computed from `budget.ts`. That arithmetic
9
+ * describes the PLANNED chrome, but the real bottom chrome grows and shrinks at
10
+ * run time: the toast row comes and goes, the hint row is dropped below
11
+ * `HINT_MIN_ROWS`, and the autocomplete popup adds up to nine rows. Routing off
12
+ * the plan would put the boundary in the wrong place exactly when the popup is
13
+ * open — the moment precision matters most (D-5 / D-16).
14
+ */
15
+ /**
16
+ * The "I do not know where the frame is" value.
17
+ *
18
+ * `App` writes it into the geometry ref on every render path that does NOT
19
+ * render the full-screen `AppShell` — the terminal-too-small placeholder and
20
+ * inline mode — so a stale measurement can never outlive the frame it
21
+ * described. There is deliberately no arithmetic fallback: an unmeasured frame
22
+ * routes to `'content'`, which is the band that cannot destroy anything, and
23
+ * one invisible frame after a resize is a better failure than a wrong band
24
+ * (§4.5, P1-5 / D-16).
25
+ */
26
+ export const UNMEASURED_GEOMETRY = { top: 1, height: 0, bottom: 0 };
27
+ /**
28
+ * Which band does terminal row `y` belong to?
29
+ *
30
+ * Fails toward `'content'`: the whole point of this feature is that a wheel
31
+ * notch must never edit the user's draft (I-3), so every degenerate input —
32
+ * nothing measured yet, a row outside the frame, a chrome measurement that
33
+ * swallowed the screen — resolves to the harmless direction.
34
+ */
35
+ export function hitTestWheel(y, geometry) {
36
+ const { top, height } = geometry;
37
+ if (!Number.isFinite(height) || height <= 0)
38
+ return 'content';
39
+ if (!Number.isFinite(y))
40
+ return 'content';
41
+ // Clamp into the frame so the one undrawn bottom row — and any off-by-one
42
+ // from a terminal that reports 0-based rows — lands on a real band instead of
43
+ // being dropped.
44
+ const row = Math.min(Math.max(Math.floor(y), top), top + height - 1);
45
+ // I-6: at least one content row always survives. This clamp is a backstop
46
+ // that should never fire, and that is checkable rather than hopeful: the
47
+ // completion popup is capped at `viewportRows(rows) - 4`, so the measured
48
+ // chrome tops out at `rows - 6` against a frame of `rows - 1` — five content
49
+ // rows survive the worst case the layout can produce.
50
+ const bottom = Math.min(Math.max(Math.floor(geometry.bottom) || 0, 0), height - 1);
51
+ return row >= top + height - bottom ? 'composer' : 'content';
52
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Scroll-thumb geometry (mouse-wheel-region-routing §4.8) — pure, React-free.
3
+ *
4
+ * Wheel scrolling without a position indicator is a half-finished affordance:
5
+ * the user learns THAT they moved but not HOW FAR or how much is left. The
6
+ * status bar's `↑N` answers neither.
7
+ *
8
+ * OFFSET COUNTS FROM THE BOTTOM, exactly as `scroll.ts` documents — `0` is
9
+ * pinned to the newest output and `overflow` is parked at the very top. The
10
+ * rows hidden ABOVE are therefore `overflow - offset`, and that inversion is
11
+ * precisely what gets done by accident, so it is computed here once and named.
12
+ */
13
+ export interface ThumbRange {
14
+ /** 0-based row of the thumb's first row, within the viewport. */
15
+ start: number;
16
+ /** Thumb height in rows; always >= 1 when a range is returned. */
17
+ size: number;
18
+ }
19
+ /**
20
+ * Where the thumb sits, or `null` when the content fits and there is nothing to
21
+ * indicate.
22
+ *
23
+ * `size` never rounds to zero: a 10-row window over a 5000-row transcript still
24
+ * gets a one-row thumb, because an indicator that vanishes at exactly the
25
+ * moment the transcript is longest is worse than no indicator at all.
26
+ */
27
+ export declare function thumbRange(viewportRows: number, contentRows: number, offsetFromBottom: number): ThumbRange | null;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Scroll-thumb geometry (mouse-wheel-region-routing §4.8) — pure, React-free.
3
+ *
4
+ * Wheel scrolling without a position indicator is a half-finished affordance:
5
+ * the user learns THAT they moved but not HOW FAR or how much is left. The
6
+ * status bar's `↑N` answers neither.
7
+ *
8
+ * OFFSET COUNTS FROM THE BOTTOM, exactly as `scroll.ts` documents — `0` is
9
+ * pinned to the newest output and `overflow` is parked at the very top. The
10
+ * rows hidden ABOVE are therefore `overflow - offset`, and that inversion is
11
+ * precisely what gets done by accident, so it is computed here once and named.
12
+ */
13
+ /**
14
+ * Where the thumb sits, or `null` when the content fits and there is nothing to
15
+ * indicate.
16
+ *
17
+ * `size` never rounds to zero: a 10-row window over a 5000-row transcript still
18
+ * gets a one-row thumb, because an indicator that vanishes at exactly the
19
+ * moment the transcript is longest is worse than no indicator at all.
20
+ */
21
+ export function thumbRange(viewportRows, contentRows, offsetFromBottom) {
22
+ const viewport = Math.floor(viewportRows);
23
+ const content = Math.floor(contentRows);
24
+ if (!Number.isFinite(viewport) || !Number.isFinite(content))
25
+ return null;
26
+ if (viewport <= 0 || content <= viewport)
27
+ return null;
28
+ const overflow = content - viewport;
29
+ const offset = Math.min(Math.max(Math.floor(offsetFromBottom) || 0, 0), overflow);
30
+ const size = Math.min(viewport, Math.max(1, Math.round((viewport * viewport) / content)));
31
+ const travel = viewport - size;
32
+ const hiddenAbove = overflow - offset;
33
+ const start = travel <= 0 ? 0 : Math.round((hiddenAbove / overflow) * travel);
34
+ return { start: Math.min(Math.max(start, 0), travel), size };
35
+ }
@@ -26,3 +26,12 @@ export declare function clampScroll(offset: number, overflowLines: number): numb
26
26
  export declare function pageSize(viewportHeight: number): number;
27
27
  export declare function isPinnedToBottom(state: ScrollState): boolean;
28
28
  export declare function applyScroll(state: ScrollState, intent: ScrollIntent, metrics: ViewportMetrics): ScrollState;
29
+ /**
30
+ * Fold `applyScroll` `times` times — one `setState`, one render, however fast
31
+ * the wheel spins (mouse-wheel-region-routing §5.5).
32
+ *
33
+ * Folding rather than multiplying is deliberate: `applyScroll` owns the
34
+ * downward bottom-snap and the clamp, and reimplementing either here would give
35
+ * the wheel a different notion of "pinned to the bottom" than the keyboard has.
36
+ */
37
+ export declare function applyScrollTimes(state: ScrollState, intent: ScrollIntent, metrics: ViewportMetrics, times: number): ScrollState;
@@ -74,3 +74,26 @@ export function applyScroll(state, intent, metrics) {
74
74
  }
75
75
  return { offset: clamped };
76
76
  }
77
+ /**
78
+ * Upper bound on one folded burst. `repeat` arrives from the wheel router's
79
+ * coalescing window, and an unbounded fold is a free CPU-burn primitive for a
80
+ * high-resolution / free-spinning wheel (R-10).
81
+ */
82
+ const MAX_SCROLL_REPEAT = 100;
83
+ /**
84
+ * Fold `applyScroll` `times` times — one `setState`, one render, however fast
85
+ * the wheel spins (mouse-wheel-region-routing §5.5).
86
+ *
87
+ * Folding rather than multiplying is deliberate: `applyScroll` owns the
88
+ * downward bottom-snap and the clamp, and reimplementing either here would give
89
+ * the wheel a different notion of "pinned to the bottom" than the keyboard has.
90
+ */
91
+ export function applyScrollTimes(state, intent, metrics, times) {
92
+ const n = Number.isFinite(times)
93
+ ? Math.min(MAX_SCROLL_REPEAT, Math.max(1, Math.floor(times)))
94
+ : 1;
95
+ let next = state;
96
+ for (let i = 0; i < n; i += 1)
97
+ next = applyScroll(next, intent, metrics);
98
+ return next;
99
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `wrapToRows` — the prose wrapper for `OverlayFrame`'s controlled (mode A)
3
+ * content (plan-mode §6.6).
4
+ *
5
+ * Mode A's contract is *one element per row, each already `wrap="truncate"`*,
6
+ * and `sliceWindow` counts ELEMENTS, not lines. Help and Settings satisfy that
7
+ * naturally because their content is a list of short rows. A plan is not: its
8
+ * summary runs to 600 characters and a step detail to 400, so handing either
9
+ * over as a single `<Text>` renders the whole paragraph as one truncated line —
10
+ * the single worst failure available to a plan review card, and one that also
11
+ * makes the frame's `1-14/22` position indicator a false statement.
12
+ *
13
+ * The repo had no wrapper before this (no `wrapText`, no `wordWrap`), so here is
14
+ * one: pure, no Ink, unit-tested.
15
+ */
16
+ /**
17
+ * Break `text` into lines of at most `width` columns.
18
+ *
19
+ * Prefers spaces; hard-breaks any single token longer than `width` (a URL or a
20
+ * long path, which must not be dropped and cannot be wrapped politely).
21
+ * Existing newlines are honoured as paragraph breaks. Returns `[]` for empty
22
+ * input so callers can splice the result without an emptiness branch.
23
+ */
24
+ export declare function wrapToRows(text: string, width: number): string[];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `wrapToRows` — the prose wrapper for `OverlayFrame`'s controlled (mode A)
3
+ * content (plan-mode §6.6).
4
+ *
5
+ * Mode A's contract is *one element per row, each already `wrap="truncate"`*,
6
+ * and `sliceWindow` counts ELEMENTS, not lines. Help and Settings satisfy that
7
+ * naturally because their content is a list of short rows. A plan is not: its
8
+ * summary runs to 600 characters and a step detail to 400, so handing either
9
+ * over as a single `<Text>` renders the whole paragraph as one truncated line —
10
+ * the single worst failure available to a plan review card, and one that also
11
+ * makes the frame's `1-14/22` position indicator a false statement.
12
+ *
13
+ * The repo had no wrapper before this (no `wrapText`, no `wordWrap`), so here is
14
+ * one: pure, no Ink, unit-tested.
15
+ */
16
+ /**
17
+ * Break `text` into lines of at most `width` columns.
18
+ *
19
+ * Prefers spaces; hard-breaks any single token longer than `width` (a URL or a
20
+ * long path, which must not be dropped and cannot be wrapped politely).
21
+ * Existing newlines are honoured as paragraph breaks. Returns `[]` for empty
22
+ * input so callers can splice the result without an emptiness branch.
23
+ */
24
+ export function wrapToRows(text, width) {
25
+ if (typeof text !== 'string' || text.length === 0)
26
+ return [];
27
+ const limit = Math.max(1, Math.floor(width));
28
+ const rows = [];
29
+ for (const paragraph of text.split('\n')) {
30
+ if (paragraph.trim().length === 0) {
31
+ // A blank line in the source is a deliberate separator; keep it as a row
32
+ // rather than collapsing it, or the summary and the steps run together.
33
+ rows.push('');
34
+ continue;
35
+ }
36
+ rows.push(...wrapParagraph(paragraph, limit));
37
+ }
38
+ return rows;
39
+ }
40
+ function wrapParagraph(paragraph, limit) {
41
+ const rows = [];
42
+ let current = '';
43
+ for (const word of paragraph.split(/\s+/).filter((w) => w.length > 0)) {
44
+ if (word.length > limit) {
45
+ // Flush what we have, then chop the over-long token into full-width
46
+ // pieces. Its tail becomes the new `current` so the next word can still
47
+ // join it.
48
+ if (current.length > 0) {
49
+ rows.push(current);
50
+ current = '';
51
+ }
52
+ let rest = word;
53
+ while (rest.length > limit) {
54
+ rows.push(rest.slice(0, limit));
55
+ rest = rest.slice(limit);
56
+ }
57
+ current = rest;
58
+ continue;
59
+ }
60
+ if (current.length === 0) {
61
+ current = word;
62
+ }
63
+ else if (current.length + 1 + word.length <= limit) {
64
+ current = `${current} ${word}`;
65
+ }
66
+ else {
67
+ rows.push(current);
68
+ current = word;
69
+ }
70
+ }
71
+ if (current.length > 0)
72
+ rows.push(current);
73
+ return rows;
74
+ }
@@ -7,11 +7,14 @@ function keyRows(times) {
7
7
  ['Enter', 'Submit (idle) / queue steering (running); re-pins to the newest output'],
8
8
  ['Alt+Enter / Shift+Enter', 'Insert newline'],
9
9
  ['Esc', 'Abort run / close overlay'],
10
+ ['Shift+Tab', 'Toggle plan mode (same as /plan)'],
10
11
  [`Ctrl+C ${times}2`, 'Exit'],
11
12
  ['Ctrl+L', 'Redraw the frame'],
12
13
  ['Ctrl+T', 'Toggle thinking blocks'],
13
14
  ['PgUp / PgDn', 'Scroll a page (transcript, or this overlay)'],
14
15
  ['Shift+Up / Shift+Down', 'Scroll the transcript a line (full-screen mode)'],
16
+ ['Wheel', 'Scroll the transcript (over the input box: prompt history)'],
17
+ ['Shift+Wheel', 'Scroll the transcript a page'],
15
18
  ['Up / Down', 'Prompt history (empty input)'],
16
19
  ];
17
20
  }
@@ -19,6 +22,7 @@ const COMMANDS = [
19
22
  ['/help', 'Show this help'],
20
23
  ['/model', 'Open the model picker'],
21
24
  ['/settings', 'Open the settings screen'],
25
+ ['/plan [on|off|status]', 'Toggle plan mode (same as Shift+Tab)'],
22
26
  ['/theme <name>', 'auto | warm | cool | light'],
23
27
  ['/thinking <level>', 'Set thinking level'],
24
28
  ['/tools', 'List active tools'],
@@ -41,6 +45,14 @@ const SKILL_ROWS = [
41
45
  ['/<skill-name> [args]', 'Run a skill directly ($ARGUMENTS / $1..$9)'],
42
46
  ['skill / skill_install', 'Tools the model uses to load and install skills'],
43
47
  ];
48
+ const PLAN_ROWS = [
49
+ ['Shift+Tab / /plan', 'Switch between BUILD (do it now) and PLAN (research first)'],
50
+ ['In PLAN mode', 'write_file, edit_file, bash, skill_install and skill_create are refused'],
51
+ ['bash', 'Refused in full, including git status - use read_file / glob / grep'],
52
+ ['ask_user', 'The agent asks 1-5 multiple-choice questions; Enter takes the recommendation'],
53
+ ['submit_plan', 'The agent submits a plan: a approve, r revise, Esc dismiss'],
54
+ ['--plan / --no-plan', 'Start a session in a given mode (also ARAGON_PLAN=1)'],
55
+ ];
44
56
  /** Build the overlay's rows. Exported so `app.test.tsx` can count them. */
45
57
  export function helpRows(theme, caps) {
46
58
  const glyphs = pickGlyphs(caps);
@@ -53,6 +65,9 @@ export function helpRows(theme, caps) {
53
65
  rows.push(section('Slash commands'));
54
66
  for (const [c, d] of COMMANDS)
55
67
  rows.push(pair(c, d));
68
+ rows.push(section('Plan mode'));
69
+ for (const [c, d] of PLAN_ROWS)
70
+ rows.push(pair(c, d));
56
71
  rows.push(section('Skills'));
57
72
  for (const [c, d] of SKILL_ROWS)
58
73
  rows.push(pair(c, d));
@@ -0,0 +1,50 @@
1
+ /**
2
+ * PlanReviewOverlay — the reviewable card `submit_plan` renders (plan-mode §6.6).
3
+ *
4
+ * `OverlayFrame` MODE A (`rows`), because a 20-step plan must scroll and mode A
5
+ * is the only mode with a position indicator.
6
+ *
7
+ * MODE A SLICES BY ELEMENT, SO THE PROSE IS PRE-WRAPPED. `sliceWindow` counts
8
+ * elements, not lines, and each element must be exactly one row. The summary
9
+ * runs to 600 characters and a step detail to 400 — handing either over as a
10
+ * single `<Text>` would render the whole paragraph as one truncated line and
11
+ * make the frame's `1-14/22` a false statement. `wrapToRows` is what keeps the
12
+ * count honest.
13
+ *
14
+ * ACTIONS LIVE IN THE FRAME HINT, NOT IN `rows`. Mode A slices `rows`; an action
15
+ * row placed there would scroll off exactly when a long plan needs it most.
16
+ * Single-key actions also avoid a second selection cursor competing with the
17
+ * scroll cursor.
18
+ *
19
+ * `Esc` IS NOT HANDLED HERE, and neither is any resolution of the pending
20
+ * request: `App` owns Esc, and the bridge's `settle()` is the only place a human
21
+ * request is answered (R-P12). That holds in the revise sub-state too, which is
22
+ * why the way back from the feedback field is `<-` rather than `Esc` (R2-P1-2).
23
+ */
24
+ import React from 'react';
25
+ import type { Theme } from '../theme.js';
26
+ import type { TermCapabilities } from '../capabilities.js';
27
+ import type { NormalizedPlan } from '../../tools/human-input.js';
28
+ export type PlanVerdict = {
29
+ decision: 'approved' | 'revise';
30
+ feedback: string;
31
+ };
32
+ interface PlanReviewOverlayProps {
33
+ plan: NormalizedPlan;
34
+ maxRows: number;
35
+ cols: number;
36
+ scrollOffset: number;
37
+ onScrollClamp: (offset: number) => void;
38
+ theme: Theme;
39
+ caps: TermCapabilities;
40
+ onVerdict: (verdict: PlanVerdict) => void;
41
+ }
42
+ export declare function PlanReviewOverlay({ plan, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onVerdict, }: PlanReviewOverlayProps): React.ReactElement;
43
+ /**
44
+ * Flatten the plan into one-row elements.
45
+ *
46
+ * Exported for `plan-mode.test.ts`, which asserts that a 600-character summary
47
+ * really does become many rows rather than one truncated line (P1-3).
48
+ */
49
+ export declare function buildPlanRows(plan: NormalizedPlan, width: number, theme: Theme, bullet: string): React.ReactElement[];
50
+ export {};
@@ -0,0 +1,117 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * PlanReviewOverlay — the reviewable card `submit_plan` renders (plan-mode §6.6).
4
+ *
5
+ * `OverlayFrame` MODE A (`rows`), because a 20-step plan must scroll and mode A
6
+ * is the only mode with a position indicator.
7
+ *
8
+ * MODE A SLICES BY ELEMENT, SO THE PROSE IS PRE-WRAPPED. `sliceWindow` counts
9
+ * elements, not lines, and each element must be exactly one row. The summary
10
+ * runs to 600 characters and a step detail to 400 — handing either over as a
11
+ * single `<Text>` would render the whole paragraph as one truncated line and
12
+ * make the frame's `1-14/22` a false statement. `wrapToRows` is what keeps the
13
+ * count honest.
14
+ *
15
+ * ACTIONS LIVE IN THE FRAME HINT, NOT IN `rows`. Mode A slices `rows`; an action
16
+ * row placed there would scroll off exactly when a long plan needs it most.
17
+ * Single-key actions also avoid a second selection cursor competing with the
18
+ * scroll cursor.
19
+ *
20
+ * `Esc` IS NOT HANDLED HERE, and neither is any resolution of the pending
21
+ * request: `App` owns Esc, and the bridge's `settle()` is the only place a human
22
+ * request is answered (R-P12). That holds in the revise sub-state too, which is
23
+ * why the way back from the feedback field is `<-` rather than `Esc` (R2-P1-2).
24
+ */
25
+ import { useState } from 'react';
26
+ import { Text, useInput } from 'ink';
27
+ import { pickGlyphs } from '../glyphs.js';
28
+ import { OverlayFrame } from '../layout/OverlayFrame.js';
29
+ import { wrapToRows } from '../layout/wrap-rows.js';
30
+ /** The frame's border plus its one-column padding on each side. */
31
+ const FRAME_CHROME_COLS = 4;
32
+ export function PlanReviewOverlay({ plan, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onVerdict, }) {
33
+ const glyphs = pickGlyphs(caps);
34
+ const [feedback, setFeedback] = useState(null);
35
+ useInput((input, key) => {
36
+ if (key.escape)
37
+ return; // App owns dismiss.
38
+ if (feedback !== null) {
39
+ if (key.return) {
40
+ onVerdict({ decision: 'revise', feedback: feedback.trim() });
41
+ return;
42
+ }
43
+ // THE WAY BACK TO THE CARD, and the reason it is `<-` and not `Esc`:
44
+ // `App` owns Esc here and settles the pending request with it, so a second
45
+ // Esc consumer in this overlay would be the `ConfirmDialog` double-resolve
46
+ // pattern R-P12 exists to keep out. `QuestionOverlay` already spends `<-`
47
+ // on "back", so this costs no key ownership. Without it the field is a
48
+ // one-way door — it consumes printable input and Backspace, and the only
49
+ // other exit throws the whole review away (R2-P1-2).
50
+ if (key.leftArrow) {
51
+ setFeedback(null);
52
+ return;
53
+ }
54
+ if (key.backspace || key.delete) {
55
+ setFeedback(feedback.slice(0, -1));
56
+ return;
57
+ }
58
+ if (input && !key.ctrl && !key.meta && !key.tab)
59
+ setFeedback(feedback + input);
60
+ return;
61
+ }
62
+ if (input === 'a' || input === 'A')
63
+ onVerdict({ decision: 'approved', feedback: '' });
64
+ else if (input === 'r' || input === 'R')
65
+ setFeedback('');
66
+ });
67
+ const width = Math.max(20, cols - FRAME_CHROME_COLS);
68
+ const rows = buildPlanRows(plan, width, theme, glyphs.bullet);
69
+ if (feedback !== null) {
70
+ // `esc dismiss`, NOT "esc back to the plan": `App` owns Esc for this overlay
71
+ // (R-P12) and settles the pending request with `null`, so Esc throws the
72
+ // whole review away from either sub-state. `<-` is the route back to the
73
+ // card, which is why it is named here too.
74
+ const feedbackHint = `${glyphs.enterKey} send ${glyphs.midDot} ${glyphs.arrowLeft} back ${glyphs.midDot} esc dismiss`;
75
+ return (_jsxs(OverlayFrame, { title: "What should change?", hint: feedbackHint, maxRows: maxRows, cols: cols, theme: theme, caps: caps, children: [_jsx(Text, { wrap: "truncate", color: theme.muted, children: "The agent revises the plan and submits it again." }), _jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: theme.primary, bold: true, children: [glyphs.caret, ' '] }), feedback.length > 0 ? (_jsx(Text, { children: feedback })) : (_jsx(Text, { color: theme.muted, children: "e.g. use the existing session store instead" })), _jsx(Text, { inverse: true, children: " " })] })] }));
76
+ }
77
+ return (_jsx(OverlayFrame, { title: "Review plan", hint: `a approve ${glyphs.midDot} r revise ${glyphs.midDot} esc dismiss ${glyphs.midDot} ${glyphs.arrowUp}${glyphs.arrowDown} scroll`, maxRows: maxRows, cols: cols, rows: rows, scrollOffset: scrollOffset, onScrollClamp: onScrollClamp, theme: theme, caps: caps }));
78
+ }
79
+ /**
80
+ * Flatten the plan into one-row elements.
81
+ *
82
+ * Exported for `plan-mode.test.ts`, which asserts that a 600-character summary
83
+ * really does become many rows rather than one truncated line (P1-3).
84
+ */
85
+ export function buildPlanRows(plan, width, theme, bullet) {
86
+ const rows = [];
87
+ let key = 0;
88
+ const push = (node, color, bold) => {
89
+ rows.push(_jsx(Text, { wrap: "truncate", color: color, bold: bold, children: node }, `p${key++}`));
90
+ };
91
+ push(plan.title, theme.assistant, true);
92
+ for (const line of wrapToRows(plan.summary, width))
93
+ push(line || ' ', theme.muted);
94
+ push(' ');
95
+ push('Steps', theme.accent, true);
96
+ plan.steps.forEach((step, i) => {
97
+ const number = String(i + 1).padStart(2, ' ');
98
+ push(`${number} ${step.title}`);
99
+ for (const line of wrapToRows(step.detail, Math.max(10, width - 4))) {
100
+ push(` ${line}`, theme.muted);
101
+ }
102
+ });
103
+ const section = (label, items) => {
104
+ if (items.length === 0)
105
+ return;
106
+ push(' ');
107
+ push(label, theme.accent, true);
108
+ for (const item of items) {
109
+ for (const line of wrapToRows(`${bullet} ${item}`, width))
110
+ push(line, theme.muted);
111
+ }
112
+ };
113
+ section('Files', plan.filesTouched);
114
+ section('Risks', plan.risks);
115
+ section('Open questions', plan.openQuestions);
116
+ return rows;
117
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * QuestionOverlay — the keyboard-driven multiple-choice wizard `ask_user`
3
+ * renders (plan-mode §6.5).
4
+ *
5
+ * `OverlayFrame` MODE B (self-managed): the content is a small state machine,
6
+ * not a list of rows, and slicing it by element would be meaningless.
7
+ *
8
+ * THE SINGLE HIGHEST-VALUE DECISION HERE is that the recommended option is the
9
+ * initial highlight for every question, so `Enter Enter Enter` is a correct,
10
+ * deliberate "use your judgement" path rather than an accident.
11
+ *
12
+ * `Esc` IS NOT HANDLED HERE. `App` owns it, and the bridge's `settle()` is the
13
+ * only place a human request is ever answered. `ConfirmDialog` does the
14
+ * opposite — both it and `App` call `resolve` — which is harmless there only by
15
+ * accident of promise semantics, and is not a pattern to copy (R-P12).
16
+ */
17
+ import React from 'react';
18
+ import type { Theme } from '../theme.js';
19
+ import type { TermCapabilities } from '../capabilities.js';
20
+ import type { Answer, NormalizedQuestion } from '../../tools/human-input.js';
21
+ interface QuestionOverlayProps {
22
+ questions: NormalizedQuestion[];
23
+ maxRows: number;
24
+ cols: number;
25
+ theme: Theme;
26
+ caps: TermCapabilities;
27
+ onSubmit: (answers: Answer[]) => void;
28
+ }
29
+ export declare function QuestionOverlay({ questions, maxRows, cols, theme, caps, onSubmit, }: QuestionOverlayProps): React.ReactElement;
30
+ export {};