@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
@@ -0,0 +1,193 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * QuestionOverlay — the keyboard-driven multiple-choice wizard `ask_user`
4
+ * renders (plan-mode §6.5).
5
+ *
6
+ * `OverlayFrame` MODE B (self-managed): the content is a small state machine,
7
+ * not a list of rows, and slicing it by element would be meaningless.
8
+ *
9
+ * THE SINGLE HIGHEST-VALUE DECISION HERE is that the recommended option is the
10
+ * initial highlight for every question, so `Enter Enter Enter` is a correct,
11
+ * deliberate "use your judgement" path rather than an accident.
12
+ *
13
+ * `Esc` IS NOT HANDLED HERE. `App` owns it, and the bridge's `settle()` is the
14
+ * only place a human request is ever answered. `ConfirmDialog` does the
15
+ * opposite — both it and `App` call `resolve` — which is harmless there only by
16
+ * accident of promise semantics, and is not a pattern to copy (R-P12).
17
+ */
18
+ import { useState } from 'react';
19
+ import { Box, Text, useInput } from 'ink';
20
+ import { pickGlyphs } from '../glyphs.js';
21
+ import { OverlayFrame } from '../layout/OverlayFrame.js';
22
+ /** Which option index each question starts on: the recommended one. */
23
+ function initialCursors(questions) {
24
+ return questions.map((q) => Math.max(0, q.options.findIndex((o) => o.recommended)));
25
+ }
26
+ function initialSelections(questions) {
27
+ return questions.map((q) => {
28
+ const recommended = q.options.findIndex((o) => o.recommended);
29
+ return new Set(recommended >= 0 ? [recommended] : [0]);
30
+ });
31
+ }
32
+ export function QuestionOverlay({ questions, maxRows, cols, theme, caps, onSubmit, }) {
33
+ const glyphs = pickGlyphs(caps);
34
+ const [step, setStep] = useState(0);
35
+ const [phase, setPhase] = useState('choosing');
36
+ const [cursors, setCursors] = useState(() => initialCursors(questions));
37
+ const [selections, setSelections] = useState(() => initialSelections(questions));
38
+ const [customs, setCustoms] = useState(() => questions.map(() => ''));
39
+ const [draft, setDraft] = useState('');
40
+ const question = questions[Math.min(step, questions.length - 1)];
41
+ const cursor = cursors[step] ?? 0;
42
+ const setCursorAt = (index, value) => setCursors((prev) => prev.map((c, i) => (i === index ? value : c)));
43
+ const setCustomAt = (index, value) => setCustoms((prev) => prev.map((c, i) => (i === index ? value : c)));
44
+ const toggleAt = (index, option) => setSelections((prev) => prev.map((set, i) => {
45
+ if (i !== index)
46
+ return set;
47
+ const next = new Set(set);
48
+ if (next.has(option))
49
+ next.delete(option);
50
+ else
51
+ next.add(option);
52
+ return next;
53
+ }));
54
+ const chooseAt = (index, option) => setSelections((prev) => prev.map((set, i) => (i === index ? new Set([option]) : set)));
55
+ const buildAnswers = () => questions.map((q, i) => {
56
+ const chosen = [...(selections[i] ?? new Set())].sort((a, b) => a - b);
57
+ const labels = chosen.map((index) => q.options[index]?.label ?? '').filter((l) => l.length > 0);
58
+ const pickedOther = chosen.some((index) => q.options[index]?.isOther);
59
+ return {
60
+ id: q.id,
61
+ question: q.question,
62
+ selected: labels,
63
+ custom: pickedOther && (customs[i] ?? '').length > 0 ? customs[i] : null,
64
+ };
65
+ });
66
+ const advance = () => {
67
+ if (step + 1 >= questions.length)
68
+ setPhase('review');
69
+ else {
70
+ setStep(step + 1);
71
+ setPhase('choosing');
72
+ }
73
+ };
74
+ const goBack = () => {
75
+ if (step === 0)
76
+ return; // No-op on the first question, by design.
77
+ setStep(step - 1);
78
+ setPhase('choosing');
79
+ };
80
+ useInput((input, key) => {
81
+ // App owns Esc for both new overlays (R-P12). Swallow it here so the
82
+ // free-text editor below cannot eat the raw escape byte as a character.
83
+ if (key.escape)
84
+ return;
85
+ if (phase === 'review') {
86
+ if (key.return) {
87
+ onSubmit(buildAnswers());
88
+ return;
89
+ }
90
+ if (key.leftArrow) {
91
+ setStep(questions.length - 1);
92
+ setPhase('choosing');
93
+ }
94
+ return;
95
+ }
96
+ if (phase === 'typingOther') {
97
+ if (key.return) {
98
+ // An empty commit falls back to the recommended option rather than
99
+ // recording a blank answer: the user pressed Enter, which everywhere
100
+ // else in this wizard means "accept the sensible default".
101
+ if (draft.trim().length === 0) {
102
+ const recommended = Math.max(0, question.options.findIndex((o) => o.recommended));
103
+ chooseAt(step, recommended);
104
+ setCursorAt(step, recommended);
105
+ setCustomAt(step, '');
106
+ }
107
+ else {
108
+ setCustomAt(step, draft.trim());
109
+ }
110
+ setDraft('');
111
+ setPhase('choosing');
112
+ advance();
113
+ return;
114
+ }
115
+ if (key.leftArrow) {
116
+ setDraft('');
117
+ setPhase('choosing');
118
+ return;
119
+ }
120
+ if (key.backspace || key.delete) {
121
+ setDraft(draft.slice(0, -1));
122
+ return;
123
+ }
124
+ if (input && !key.ctrl && !key.meta && !key.tab)
125
+ setDraft(draft + input);
126
+ return;
127
+ }
128
+ // phase === 'choosing'
129
+ if (key.upArrow) {
130
+ setCursorAt(step, (cursor - 1 + question.options.length) % question.options.length);
131
+ return;
132
+ }
133
+ if (key.downArrow) {
134
+ setCursorAt(step, (cursor + 1) % question.options.length);
135
+ return;
136
+ }
137
+ if (key.leftArrow) {
138
+ goBack();
139
+ return;
140
+ }
141
+ if (input === ' ' && question.allowMultiple) {
142
+ toggleAt(step, cursor);
143
+ return;
144
+ }
145
+ if (key.return) {
146
+ if (question.options[cursor]?.isOther) {
147
+ chooseAt(step, cursor);
148
+ setDraft(customs[step] ?? '');
149
+ setPhase('typingOther');
150
+ return;
151
+ }
152
+ if (!question.allowMultiple)
153
+ chooseAt(step, cursor);
154
+ else if (!(selections[step] ?? new Set()).has(cursor))
155
+ toggleAt(step, cursor);
156
+ advance();
157
+ }
158
+ });
159
+ const title = phase === 'review' ? 'Review your answers' : 'Clarify the request';
160
+ const position = phase === 'review' ? '' : ` ${step + 1}/${questions.length}`;
161
+ const hint = phase === 'typingOther'
162
+ ? `type your answer ${glyphs.midDot} ${glyphs.enterKey} commit ${glyphs.midDot} ${glyphs.arrowLeft} back ${glyphs.midDot} esc cancel`
163
+ : phase === 'review'
164
+ ? `${glyphs.enterKey} submit ${glyphs.midDot} ${glyphs.arrowLeft} back ${glyphs.midDot} esc cancel`
165
+ : `${glyphs.arrowUp}${glyphs.arrowDown} choose ${glyphs.midDot} ${glyphs.enterKey} next${question.allowMultiple ? ` ${glyphs.midDot} space toggle` : ''} ${glyphs.midDot} ${glyphs.arrowLeft} back ${glyphs.midDot} esc cancel`;
166
+ return (_jsx(OverlayFrame, { title: `${title}${position}`, hint: hint, maxRows: maxRows, cols: cols, theme: theme, caps: caps, children: phase === 'review' ? (_jsx(ReviewBody, { questions: questions, answers: buildAnswers(), theme: theme })) : (_jsx(QuestionBody, { question: question, cursor: cursor, selected: selections[step] ?? new Set(), typing: phase === 'typingOther', draft: draft, theme: theme, caps: caps })) }));
167
+ }
168
+ /**
169
+ * ONE QUESTION PER SCREEN, not all of them at once. Three questions x four
170
+ * options x a description line is 20+ rows against a ~15-row viewport on a
171
+ * 24-row terminal.
172
+ */
173
+ function QuestionBody({ question, cursor, selected, typing, draft, theme, caps, }) {
174
+ const glyphs = pickGlyphs(caps);
175
+ const markOn = question.allowMultiple ? glyphs.boxChecked : glyphs.keyOn;
176
+ const markOff = question.allowMultiple ? glyphs.boxEmpty : glyphs.keyOff;
177
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { wrap: "truncate", color: theme.accent, bold: true, children: question.header }), _jsx(Text, { wrap: "truncate", color: theme.assistant, children: question.question }), _jsx(Text, { children: " " }), question.options.map((option, i) => {
178
+ const active = i === cursor;
179
+ const on = selected.has(i);
180
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: active ? theme.primary : theme.muted, children: [active ? glyphs.caret : ' ', ' '] }), _jsxs(Text, { color: on ? theme.primary : theme.muted, children: [on ? markOn : markOff, " "] }), _jsx(Text, { color: active ? theme.assistant : undefined, bold: active, children: option.isOther ? `${option.label}${glyphs.ellipsis}` : option.label }), option.description.length > 0 && (_jsxs(Text, { color: theme.muted, children: [' ', option.description] })), option.recommended && (_jsxs(Text, { backgroundColor: theme.chip.bg, color: theme.chip.fg, children: [' ', "RECOMMENDED", ' '] }))] }, `${question.id}-${i}`));
181
+ }), typing && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: theme.primary, bold: true, children: [glyphs.caret, ' '] }), draft.length > 0 ? (_jsx(Text, { children: draft })) : (_jsx(Text, { color: theme.muted, children: "type your own answer" })), _jsx(Text, { inverse: true, children: " " })] })] }))] }));
182
+ }
183
+ /**
184
+ * The review step exists because a wizard that fires on the last `Enter` with no
185
+ * confirmation makes a mis-keyed answer unrecoverable.
186
+ */
187
+ function ReviewBody({ questions, answers, theme, }) {
188
+ return (_jsx(Box, { flexDirection: "column", children: questions.map((q, i) => {
189
+ const answer = answers[i];
190
+ const value = answer?.custom ?? answer?.selected.join(', ') ?? '';
191
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: theme.accent, children: q.header.padEnd(14) }), _jsx(Text, { color: theme.assistant, children: value })] }, q.id));
192
+ }) }));
193
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import React from 'react';
11
11
  import type { ThinkingLevel } from '@aragon-agent/core';
12
+ import { type LogLevelName } from '../../logging/levels.js';
12
13
  import type { Theme } from '../theme.js';
13
14
  import type { TermCapabilities } from '../capabilities.js';
14
15
  export interface SettingsValues {
@@ -18,6 +19,12 @@ export interface SettingsValues {
18
19
  thinkingLevel: ThinkingLevel;
19
20
  maxTokens: string;
20
21
  apiKey: string;
22
+ /**
23
+ * Only the LEVEL is editable here. The rest of `LogConfig` — and above all
24
+ * `redactSecrets` — stays command-line-only, so the one switch that can put
25
+ * credentials on disk cannot be flipped by a stray arrow key.
26
+ */
27
+ logLevel: LogLevelName;
21
28
  }
22
29
  interface SettingsScreenProps {
23
30
  initial: SettingsValues;
@@ -11,6 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  import { useState } from 'react';
12
12
  import { Text, useInput } from 'ink';
13
13
  import { ADAPTER_PROVIDERS, THINKING_LEVELS, maskSecret } from '../../config/schema.js';
14
+ import { LOG_LEVEL_NAMES } from '../../logging/levels.js';
14
15
  import { pickGlyphs } from '../glyphs.js';
15
16
  import { OverlayFrame } from '../layout/OverlayFrame.js';
16
17
  const FIELDS = [
@@ -20,6 +21,7 @@ const FIELDS = [
20
21
  { key: 'thinkingLevel', label: 'Thinking', kind: 'enum', options: THINKING_LEVELS },
21
22
  { key: 'maxTokens', label: 'Max tokens', kind: 'text' },
22
23
  { key: 'apiKey', label: 'API key', kind: 'secret' },
24
+ { key: 'logLevel', label: 'Log level', kind: 'enum', options: LOG_LEVEL_NAMES },
23
25
  ];
24
26
  export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onSave, }) {
25
27
  const [values, setValues] = useState(initial);
@@ -51,7 +53,12 @@ export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset,
51
53
  setIndex((i) => (i - 1 + FIELDS.length) % FIELDS.length);
52
54
  return;
53
55
  }
54
- if (key.downArrow || key.tab) {
56
+ // `&& !key.shift` is a real key collision, not a style choice: Shift+Tab is
57
+ // the global mode toggle, and without the guard it would also advance the
58
+ // field cursor here — the user changes mode and their selection jumps
59
+ // (AC-P4). The mode toggle itself is a no-op while an overlay is open, so
60
+ // this branch simply has to stay out of the way.
61
+ if (key.downArrow || (key.tab && !key.shift)) {
55
62
  setIndex((i) => (i + 1) % FIELDS.length);
56
63
  return;
57
64
  }
@@ -15,11 +15,24 @@ export interface ScreenHandle {
15
15
  /** Leave the alternate screen. Idempotent — safe from every exit path. */
16
16
  restore(): void;
17
17
  }
18
+ export interface AltScreenOptions {
19
+ /**
20
+ * TRUE MEANS "A MOUSE FILTER IS ALREADY INSTALLED", NOT "THE USER WANTS MOUSE
21
+ * SUPPORT" (invariant I-8).
22
+ *
23
+ * This module must never derive the value from config, env or platform.
24
+ * Enabling reporting without a filter in front of Ink does not degrade the
25
+ * feature — it types `[<0;12;5M` into the user's message on every click and
26
+ * every notch, which is strictly worse than the bug the feature fixes. The
27
+ * only caller is `cli.tsx::runInteractive`, and it passes `filter !== null`.
28
+ */
29
+ readonly mouse: boolean;
30
+ }
18
31
  /**
19
32
  * Enter the alternate screen. A non-TTY stdout is a hard no-op (tests, pipes,
20
33
  * CI) — writing screen-control sequences into a redirected file corrupts it.
21
34
  */
22
- export declare function enterAltScreen(stdout: NodeJS.WriteStream | undefined): ScreenHandle;
35
+ export declare function enterAltScreen(stdout: NodeJS.WriteStream | undefined, options?: AltScreenOptions): ScreenHandle;
23
36
  /**
24
37
  * Replay the session summary into the normal buffer after `restore()`, so the
25
38
  * conversation does not evaporate along with the alternate screen.
package/dist/ui/screen.js CHANGED
@@ -17,6 +17,32 @@ const CURSOR_HOME = '\x1b[H';
17
17
  const CLEAR_VISIBLE = '\x1b[2J';
18
18
  /** Ink's log-update hides the cursor; a signal kill never gives it back. */
19
19
  const SHOW_CURSOR = '\x1b[?25h';
20
+ /**
21
+ * `?1000` is NORMAL TRACKING (press / release), the minimum mode that reports
22
+ * wheel buttons; `?1006` selects SGR encoding so coordinates arrive as decimal
23
+ * text rather than raw bytes above 0x7F that `setEncoding('utf8')` would
24
+ * mangle. Motion tracking (`?1002` / `?1003`) is deliberately NOT enabled: it
25
+ * floods stdin on every pointer move and buys nothing this feature uses.
26
+ */
27
+ const ENABLE_MOUSE = '\x1b[?1000h\x1b[?1006h';
28
+ const DISABLE_MOUSE = '\x1b[?1006l\x1b[?1000l';
29
+ /**
30
+ * Alternate scroll (DEC private mode 1007) is what turns a wheel notch into a
31
+ * burst of arrow keys, which `PromptInput` reads as prompt-history recall. With
32
+ * mouse reporting ON we do not touch it at all: 1007 is defined to translate
33
+ * the wheel only while no application is tracking the mouse, so `?1000h`
34
+ * already suppresses it and a second mutation of shared state buys nothing.
35
+ *
36
+ * With mouse reporting OFF we turn it off so the wheel is INERT rather than
37
+ * destructive — the fallback rung of the fail-safe ladder. But 1007 is GLOBAL
38
+ * TERMINAL STATE THIS APP DOES NOT OWN, so it is SAVED and RESTORED (XTSAVE /
39
+ * XTRESTORE) rather than force-set on the way out: a user who deliberately
40
+ * disabled alternate scroll in their terminal config must not have it switched
41
+ * back on by running `aragon --no-mouse` once. A terminal implementing neither
42
+ * sequence ignores both, so the fallback is no worse than writing nothing.
43
+ */
44
+ const SAVE_ALT_SCROLL_OFF = '\x1b[?1007s\x1b[?1007l';
45
+ const RESTORE_ALT_SCROLL = '\x1b[?1007r';
20
46
  const NOOP_HANDLE = { restore() { } };
21
47
  function safeWrite(stdout, data) {
22
48
  try {
@@ -30,17 +56,25 @@ function safeWrite(stdout, data) {
30
56
  * Enter the alternate screen. A non-TTY stdout is a hard no-op (tests, pipes,
31
57
  * CI) — writing screen-control sequences into a redirected file corrupts it.
32
58
  */
33
- export function enterAltScreen(stdout) {
59
+ export function enterAltScreen(stdout, options = { mouse: false }) {
34
60
  if (!stdout || !stdout.isTTY || typeof stdout.write !== 'function')
35
61
  return NOOP_HANDLE;
36
- safeWrite(stdout, ENTER_ALT + CLEAR_VISIBLE + CURSOR_HOME);
62
+ const mouse = options.mouse === true;
63
+ safeWrite(stdout, ENTER_ALT + CLEAR_VISIBLE + CURSOR_HOME + (mouse ? ENABLE_MOUSE : SAVE_ALT_SCROLL_OFF));
37
64
  let restored = false;
38
65
  return {
39
66
  restore() {
40
67
  if (restored)
41
68
  return;
42
69
  restored = true;
43
- safeWrite(stdout, SHOW_CURSOR + LEAVE_ALT);
70
+ // Mouse reporting is disabled BEFORE leaving the alternate screen (I-2).
71
+ // This handle is what all four exit paths converge on — `process.on
72
+ // ('exit')`, the crash handler's `setScreenRestore`, the signal
73
+ // terminator, and `waitUntilExit()` — which is why the sequences live
74
+ // here and not in `App.tsx`, a component that is on none of them.
75
+ // Leaving reporting on prints `[<0;12;5M` in the user's shell on every
76
+ // click, forever, until they run `reset`.
77
+ safeWrite(stdout, (mouse ? DISABLE_MOUSE : RESTORE_ALT_SCROLL) + SHOW_CURSOR + LEAVE_ALT);
44
78
  },
45
79
  };
46
80
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The two things the user needs to be told about that happened BEFORE the frame
3
+ * existed, and the one that can happen at any moment afterwards.
4
+ *
5
+ * Both arrive as toasts, and that is not a style choice: with the TUI mounted, a
6
+ * toast is the only legal user-visible channel. Any direct write to stdout or
7
+ * stderr shifts the fixed frame by a row and breaks its line accounting for good
8
+ * (invariant I-4).
9
+ *
10
+ * Lives outside `App.tsx` because that file is already at its size ceiling, and
11
+ * because these two effects share one job — surfacing a condition the user could
12
+ * not otherwise see — rather than belonging to the view.
13
+ */
14
+ import type { ViewAction } from '../agent/reducer.js';
15
+ export interface MouseNoticeOptions {
16
+ /**
17
+ * Whether reporting is ACTUALLY in effect — i.e. a filter was installed, not
18
+ * merely that the config asked for one. A user on a terminal where R-1 fires
19
+ * must never be given advice about a mode that is not running.
20
+ */
21
+ enabled: boolean;
22
+ /** Persist `mouseNoticeSeen`. Called once, after the notice is dispatched. */
23
+ onSeen: () => void;
24
+ }
25
+ export declare function useStartupNotices(dispatch: (action: ViewAction) => void, mouseNotice?: MouseNoticeOptions): void;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The two things the user needs to be told about that happened BEFORE the frame
3
+ * existed, and the one that can happen at any moment afterwards.
4
+ *
5
+ * Both arrive as toasts, and that is not a style choice: with the TUI mounted, a
6
+ * toast is the only legal user-visible channel. Any direct write to stdout or
7
+ * stderr shifts the fixed frame by a row and breaks its line accounting for good
8
+ * (invariant I-4).
9
+ *
10
+ * Lives outside `App.tsx` because that file is already at its size ceiling, and
11
+ * because these two effects share one job — surfacing a condition the user could
12
+ * not otherwise see — rather than belonging to the view.
13
+ */
14
+ import { useEffect, useRef } from 'react';
15
+ import { readConfigFile } from '../config/store.js';
16
+ import { getLogger } from '../logging/logger.js';
17
+ /**
18
+ * The §6.1 notice (mouse-wheel-region-routing). Shown at most once, ever.
19
+ *
20
+ * R-3 is the only part of wheel region routing that reaches users who never
21
+ * scroll, and it REMOVES a gesture — drag to select — that is used more often
22
+ * than the one being added. A README is consulted only after the belief
23
+ * "aragon broke copy/paste" has already formed.
24
+ */
25
+ const MOUSE_SELECTION_NOTICE = 'Mouse wheel scrolls the transcript. Hold Shift to select text, ' +
26
+ 'or turn it off with `aragon config set mouse false`.';
27
+ export function useStartupNotices(dispatch, mouseNotice) {
28
+ // The sink gives up after repeated write failures (a read-only log directory,
29
+ // a full disk). Silently logging nothing is exactly the state a user must not
30
+ // discover later, from an empty file, while trying to explain a bug.
31
+ useEffect(() => {
32
+ getLogger().onFailure((reason) => dispatch({ type: 'pushToast', level: 'warn', text: `Logging stopped: ${reason}` }));
33
+ }, [dispatch]);
34
+ // `main()` already wrote this to stderr, but entering the alternate screen
35
+ // wipes it — so full-screen users would be left with the very failure the
36
+ // message exists to prevent: every setting back at its default, and nothing
37
+ // anywhere connecting that to the comma they typed into config.json.
38
+ useEffect(() => {
39
+ const { parseError } = readConfigFile();
40
+ if (!parseError)
41
+ return;
42
+ dispatch({
43
+ type: 'pushToast',
44
+ level: 'warn',
45
+ text: `Using default settings: could not parse ${parseError}`,
46
+ });
47
+ }, [dispatch]);
48
+ // A `notice`, not a toast: it lands in the transcript and scrolls away
49
+ // instead of stealing a row. §6's argument against toasts is about per-notch
50
+ // chatter and does not extend to a one-shot.
51
+ const noticeRef = useRef(mouseNotice);
52
+ noticeRef.current = mouseNotice;
53
+ const mouseEnabled = mouseNotice?.enabled ?? false;
54
+ const mouseNoticeShown = useRef(false);
55
+ useEffect(() => {
56
+ if (!mouseEnabled || mouseNoticeShown.current)
57
+ return;
58
+ if (readConfigFile().config?.mouseNoticeSeen)
59
+ return;
60
+ mouseNoticeShown.current = true;
61
+ dispatch({ type: 'notice', level: 'info', text: MOUSE_SELECTION_NOTICE });
62
+ noticeRef.current?.onSeen();
63
+ }, [mouseEnabled, dispatch]);
64
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Wheel routing (mouse-wheel-region-routing §4.6 / §4.7).
3
+ *
4
+ * Subscribes to the mouse source, turns each wheel notch into a band via
5
+ * `hitTestWheel`, and dispatches it to the transcript, an open overlay, or the
6
+ * prompt-history stepper. Lives outside `App.tsx` because that file is exactly
7
+ * at the 1000-line ceiling `CLAUDE.md` sets, and because the routing table is
8
+ * the most testable part of this feature and does not want to be inside a
9
+ * 1000-line component (D-13).
10
+ *
11
+ * THE TWO BANDS HAVE DIFFERENT PHYSICS, and that is the requirement rather than
12
+ * a tuning choice (§4.7). A physical notch produces ONE SGR report, whereas
13
+ * alternate-scroll used to produce three arrow keys:
14
+ *
15
+ * - transcript: 3 rows per notch, matching the terminal's own convention and
16
+ * the muscle memory users bring from every other full-screen app;
17
+ * - composer: exactly ONE history step per notch. Multiplying here would make
18
+ * a single flick jump three prompts back — unusable, and destructive when a
19
+ * draft is in play.
20
+ *
21
+ * Bursts are handled differently for the same reason: transcript events are
22
+ * coalesced into one intent carrying `repeat`, composer events never are (they
23
+ * are rate-limited by the human, and each must produce its own visible step).
24
+ */
25
+ import { type MutableRefObject } from 'react';
26
+ import type { Overlay } from '../agent/reducer.js';
27
+ import type { MouseSource } from '../input/stdin-mouse-filter.js';
28
+ import { type FrameGeometry } from './layout/regions.js';
29
+ import type { ScrollIntent } from './layout/scroll.js';
30
+ /**
31
+ * Rows a PgUp/PgDn — and a Shift+wheel notch — moves inside a controlled
32
+ * overlay. Declared here rather than in `App.tsx` because both the keyboard
33
+ * branch and the wheel branch need it and `App` imports this module; the other
34
+ * direction would be a cycle.
35
+ */
36
+ export declare const OVERLAY_PAGE = 8;
37
+ export interface WheelRoutingOptions {
38
+ /** Absent when mouse support is off, in inline mode, or on a non-TTY. */
39
+ mouseSource?: MouseSource;
40
+ /** Live frame geometry, written by `AppShell`'s layout effect (§4.5). */
41
+ geometryRef: MutableRefObject<FrameGeometry>;
42
+ /**
43
+ * Test-only seam (§8.2): when set, the router reads this instead of the
44
+ * measured ref, so a routing test never depends on `measureElement` working
45
+ * under `ink-testing-library` — a dependency no other test in this package
46
+ * exercises, and one that would make a routing regression present as a
47
+ * measurement flake.
48
+ */
49
+ geometryOverride?: FrameGeometry;
50
+ /** Read at EVENT time for the routing branch, and again at FLUSH time (P1-3). */
51
+ getOverlay: () => Overlay | null;
52
+ onScroll: (kind: ScrollIntent, repeat: number) => void;
53
+ /** Signed row delta; the caller clamps at 0 the way the keyboard branch does. */
54
+ onOverlayScroll: (delta: number) => void;
55
+ onHistoryStep: (dir: 'up' | 'down') => void;
56
+ }
57
+ export interface WheelRouting {
58
+ /**
59
+ * Drop any accumulated-but-unflushed transcript scroll.
60
+ *
61
+ * Called by `App` from its existing `state.overlay` effect rather than from a
62
+ * second subscriber to the same transition. This is guard #2 of the pair that
63
+ * has to ship together (§14 condition 3); guard #1 is the overlay re-check
64
+ * inside the flush, and guard #3 is `ScrollViewport`'s I-9 mount-skip.
65
+ */
66
+ clearCoalescer: () => void;
67
+ }
68
+ export declare function useWheelRouting(options: WheelRoutingOptions): WheelRouting;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Wheel routing (mouse-wheel-region-routing §4.6 / §4.7).
3
+ *
4
+ * Subscribes to the mouse source, turns each wheel notch into a band via
5
+ * `hitTestWheel`, and dispatches it to the transcript, an open overlay, or the
6
+ * prompt-history stepper. Lives outside `App.tsx` because that file is exactly
7
+ * at the 1000-line ceiling `CLAUDE.md` sets, and because the routing table is
8
+ * the most testable part of this feature and does not want to be inside a
9
+ * 1000-line component (D-13).
10
+ *
11
+ * THE TWO BANDS HAVE DIFFERENT PHYSICS, and that is the requirement rather than
12
+ * a tuning choice (§4.7). A physical notch produces ONE SGR report, whereas
13
+ * alternate-scroll used to produce three arrow keys:
14
+ *
15
+ * - transcript: 3 rows per notch, matching the terminal's own convention and
16
+ * the muscle memory users bring from every other full-screen app;
17
+ * - composer: exactly ONE history step per notch. Multiplying here would make
18
+ * a single flick jump three prompts back — unusable, and destructive when a
19
+ * draft is in play.
20
+ *
21
+ * Bursts are handled differently for the same reason: transcript events are
22
+ * coalesced into one intent carrying `repeat`, composer events never are (they
23
+ * are rate-limited by the human, and each must produce its own visible step).
24
+ */
25
+ import { useCallback, useEffect, useRef } from 'react';
26
+ import { hitTestWheel } from './layout/regions.js';
27
+ /**
28
+ * Rows a PgUp/PgDn — and a Shift+wheel notch — moves inside a controlled
29
+ * overlay. Declared here rather than in `App.tsx` because both the keyboard
30
+ * branch and the wheel branch need it and `App` imports this module; the other
31
+ * direction would be a cycle.
32
+ */
33
+ export const OVERLAY_PAGE = 8;
34
+ /** Transcript rows per notch. The terminal's own convention. */
35
+ const CONTENT_LINES_PER_NOTCH = 3;
36
+ /** Overlay rows per notch. Overlay content is short; a page needs Shift. */
37
+ const OVERLAY_LINES_PER_NOTCH = 3;
38
+ /**
39
+ * Coalescing window for transcript scrolling — one `setState`, one render, no
40
+ * matter how fast the wheel spins (R-10). Deliberately tighter than the 33 ms
41
+ * streaming coalescer: this one is in the direct path of a hand gesture.
42
+ */
43
+ const WHEEL_COALESCE_MS = 16;
44
+ export function useWheelRouting(options) {
45
+ // Every mutable input reaches the subscription through this ref, so the
46
+ // effect below subscribes exactly once per `mouseSource` instead of tearing
47
+ // the listener down and rebuilding it on every parent render.
48
+ const optionsRef = useRef(options);
49
+ optionsRef.current = options;
50
+ const accumulator = useRef(null);
51
+ const timer = useRef(null);
52
+ const clearCoalescer = useCallback(() => {
53
+ if (timer.current) {
54
+ clearTimeout(timer.current);
55
+ timer.current = null;
56
+ }
57
+ accumulator.current = null;
58
+ }, []);
59
+ const flush = useCallback(() => {
60
+ if (timer.current) {
61
+ clearTimeout(timer.current);
62
+ timer.current = null;
63
+ }
64
+ const pending = accumulator.current;
65
+ accumulator.current = null;
66
+ if (!pending)
67
+ return;
68
+ // P1-3: THE DECISION BELONGS AT FLUSH TIME, not at event time, because the
69
+ // event-time decision is the one that goes stale. `App` swaps
70
+ // `ScrollViewport` out for an overlay, and 16 ms is more than wide enough
71
+ // for an agent-raised `confirm` or an `ask_user` to land inside the window
72
+ // — the intent would then be applied on a component that is not there, and
73
+ // the transcript would jump by itself when the overlay closed.
74
+ if (optionsRef.current.getOverlay())
75
+ return;
76
+ optionsRef.current.onScroll(pending.kind, pending.repeat);
77
+ }, []);
78
+ const accumulate = useCallback((kind, repeat) => {
79
+ // A fast up-then-down must never cancel itself into a no-op, so a change
80
+ // of direction (or of granularity) flushes what is already held.
81
+ if (accumulator.current && accumulator.current.kind !== kind)
82
+ flush();
83
+ const held = accumulator.current;
84
+ accumulator.current = { kind, repeat: (held?.repeat ?? 0) + repeat };
85
+ if (!timer.current) {
86
+ timer.current = setTimeout(flush, WHEEL_COALESCE_MS);
87
+ timer.current.unref?.();
88
+ }
89
+ }, [flush]);
90
+ const route = useCallback((event) => {
91
+ const current = optionsRef.current;
92
+ const geometry = current.geometryOverride ?? current.geometryRef.current;
93
+ const band = hitTestWheel(event.y, geometry);
94
+ const overlay = current.getOverlay();
95
+ // THE OVERLAY BRANCH IS EVALUATED FIRST AND RETURNS FOR EVERY BAND IT
96
+ // RECOGNIZES — the same two ordering rules the keyboard handler follows,
97
+ // and for the same reason (R-P1-7).
98
+ if (overlay) {
99
+ // The composer is inactive behind an overlay; stepping a hidden draft
100
+ // is worse than doing nothing.
101
+ if (band !== 'content')
102
+ return;
103
+ // Modes A only. `model` / `confirm` / `question` own their own keys and
104
+ // scrolling them would leave an inconsistent cursor (R-11).
105
+ const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan';
106
+ if (!controlled)
107
+ return;
108
+ const step = event.shift ? OVERLAY_PAGE : OVERLAY_LINES_PER_NOTCH;
109
+ current.onOverlayScroll(event.dir === 'up' ? -step : step);
110
+ return;
111
+ }
112
+ if (band === 'composer') {
113
+ // §4.7: the accumulator flushes when the BAND changes, not only when
114
+ // the direction does. The composer path is never coalesced, so without
115
+ // this the transcript scroll that preceded the pointer moving down
116
+ // lands ~16 ms AFTER the history step it happened before — the two
117
+ // halves of one gesture, applied out of order.
118
+ flush();
119
+ current.onHistoryStep(event.dir);
120
+ return;
121
+ }
122
+ if (event.shift) {
123
+ accumulate(event.dir === 'up' ? 'pageUp' : 'pageDown', 1);
124
+ return;
125
+ }
126
+ accumulate(event.dir === 'up' ? 'lineUp' : 'lineDown', CONTENT_LINES_PER_NOTCH);
127
+ }, [accumulate, flush]);
128
+ const { mouseSource } = options;
129
+ useEffect(() => {
130
+ if (!mouseSource)
131
+ return undefined;
132
+ const unsubscribe = mouseSource.subscribe(route);
133
+ return () => {
134
+ unsubscribe();
135
+ clearCoalescer();
136
+ };
137
+ }, [mouseSource, route, clearCoalescer]);
138
+ return { clearCoalescer };
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "files": [
34
34
  "dist",
35
+ "runtime",
35
36
  "skills",
36
37
  "README.md",
37
38
  "CHANGELOG.md"
@@ -50,7 +51,7 @@
50
51
  "prepublishOnly": "npm run build"
51
52
  },
52
53
  "dependencies": {
53
- "@aragon-agent/core": "^0.2.0",
54
+ "@aragon-agent/core": "^0.2.2",
54
55
  "chalk": "^5.6.2",
55
56
  "cli-highlight": "^2.0.0",
56
57
  "commander": "^12.1.0",