@aragon-agent/cli 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +27 -4
  3. package/dist/cli.js +65 -4
  4. package/dist/config/env.d.ts +2 -2
  5. package/dist/config/env.js +10 -2
  6. package/dist/config/load.d.ts +2 -0
  7. package/dist/config/load.js +23 -0
  8. package/dist/config/schema.d.ts +20 -0
  9. package/dist/config/schema.js +2 -0
  10. package/dist/input/mouse-events.d.ts +61 -0
  11. package/dist/input/mouse-events.js +153 -0
  12. package/dist/input/stdin-mouse-filter.d.ts +42 -0
  13. package/dist/input/stdin-mouse-filter.js +153 -0
  14. package/dist/ui/App.d.ts +9 -1
  15. package/dist/ui/App.js +45 -10
  16. package/dist/ui/Composer.d.ts +6 -1
  17. package/dist/ui/Composer.js +2 -2
  18. package/dist/ui/PromptInput.d.ts +15 -1
  19. package/dist/ui/PromptInput.js +38 -1
  20. package/dist/ui/glyphs.d.ts +12 -0
  21. package/dist/ui/glyphs.js +4 -0
  22. package/dist/ui/layout/AppShell.d.ts +10 -1
  23. package/dist/ui/layout/AppShell.js +38 -3
  24. package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
  25. package/dist/ui/layout/ScrollIndicator.js +15 -0
  26. package/dist/ui/layout/ScrollViewport.d.ts +8 -2
  27. package/dist/ui/layout/ScrollViewport.js +24 -4
  28. package/dist/ui/layout/regions.d.ts +55 -0
  29. package/dist/ui/layout/regions.js +52 -0
  30. package/dist/ui/layout/scroll-indicator.d.ts +27 -0
  31. package/dist/ui/layout/scroll-indicator.js +35 -0
  32. package/dist/ui/layout/scroll.d.ts +9 -0
  33. package/dist/ui/layout/scroll.js +23 -0
  34. package/dist/ui/overlays/HelpOverlay.js +2 -0
  35. package/dist/ui/screen.d.ts +14 -1
  36. package/dist/ui/screen.js +37 -3
  37. package/dist/ui/use-startup-notices.d.ts +11 -1
  38. package/dist/ui/use-startup-notices.js +28 -2
  39. package/dist/ui/use-wheel-routing.d.ts +68 -0
  40. package/dist/ui/use-wheel-routing.js +139 -0
  41. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ were written.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ### Added
11
+
12
+ - **The mouse wheel scrolls what is under the pointer.** In full-screen mode the
13
+ wheel now scrolls the transcript three rows per notch (a page with `Shift`),
14
+ scrolls an open overlay when the pointer is over one, and steps prompt history
15
+ one entry per notch when the pointer is over the input box — the same code
16
+ path `↑` / `↓` use, so it inherits the rule that an in-progress draft is never
17
+ overwritten. A slim rail on the viewport's right edge shows where you are in
18
+ the conversation.
19
+
20
+ **This fixes a destructive bug.** Previously the wheel recalled prompt history
21
+ no matter where the pointer was, because the alternate screen makes terminals
22
+ translate each notch into arrow keys. Wheeling while composing a message
23
+ silently replaced the draft with an old prompt, and nothing scrolled.
24
+
25
+ **Selecting text with the mouse now requires `Shift`+drag** in most terminals.
26
+ That is the standard convention for "an application is tracking the mouse" and
27
+ it applies to everyone, including people who never scroll with the wheel — so
28
+ the first session says so once, in the transcript, and never again. Turn the
29
+ whole feature off with `--no-mouse`, `ARAGON_MOUSE=0`, or
30
+ `aragon config set mouse false`; the wheel then does nothing rather than
31
+ reverting to the old behaviour. Inline mode (`--no-fullscreen`) is untouched:
32
+ your terminal's own scrollback already does the right thing there.
33
+
34
+ Under `tmux` with `mouse on`, tmux keeps the wheel for itself and the app
35
+ never sees it. `set -g mouse off` hands it back.
36
+
10
37
  ### Changed
11
38
 
12
39
  - **Your files moved to `~/.aragon-agent/`.** Config, sessions, installed skills
package/README.md CHANGED
@@ -123,6 +123,8 @@ completes, `Up` / `Down` moves the selection, `Esc` closes it.
123
123
  | `Ctrl+O` | Expand / collapse the most recent tool card |
124
124
  | `PgUp` / `PgDn` | Scroll the transcript a page (full-screen mode) |
125
125
  | `Shift+↑` / `Shift+↓` | Scroll the transcript a line (full-screen mode) |
126
+ | Mouse wheel | Scroll the transcript (over the input box: prompt history) |
127
+ | `Shift`+wheel | Scroll the transcript a page |
126
128
  | `Home` / `End`, `Ctrl+A` / `Ctrl+E` | Cursor to line start / end |
127
129
  | `Alt/Ctrl+←` / `→` | Word-wise cursor jump |
128
130
  | `Ctrl+W`, `Alt+Backspace` | Delete the previous word |
@@ -132,10 +134,31 @@ completes, `Up` / `Down` moves the selection, `Esc` closes it.
132
134
 
133
135
  In **full-screen mode** the transcript is scrolled by the app itself: `PgUp` /
134
136
  `PgDn` and `Shift+↑` / `Shift+↓`. While pinned to the bottom the viewport follows
135
- new output automatically; once you scroll away the status bar shows `↑N` and a
136
- hint counts the lines below you. Submitting a message always re-pins to the
137
- newest output. In **inline mode** (`--no-fullscreen`) history lives in your
138
- terminal's native scrollback, printed once via Ink `<Static>`.
137
+ new output automatically; once you scroll away the status bar shows `↑N`, a slim
138
+ rail on the right edge shows where you are, and a hint counts the lines below
139
+ you. Submitting a message always re-pins to the newest output. In **inline mode**
140
+ (`--no-fullscreen`) history lives in your terminal's native scrollback, printed
141
+ once via Ink `<Static>`.
142
+
143
+ **The mouse wheel scrolls whatever is under the pointer.** Over the transcript
144
+ it scrolls the transcript three rows per notch (a page with `Shift`); over an
145
+ open overlay it scrolls that overlay; over the input box at the bottom it steps
146
+ prompt history one entry per notch, exactly as `↑` / `↓` do — including the rule
147
+ that an in-progress draft is never overwritten.
148
+
149
+ Two things follow from the app owning the mouse, and they are worth knowing
150
+ before you meet them:
151
+
152
+ - **Selecting text now needs `Shift`+drag** in most terminals. That is the
153
+ terminal's convention for "an application is tracking the mouse", not
154
+ something this CLI can opt out of while still reading wheel events.
155
+ - **`tmux` with `mouse on` keeps the wheel for itself**, so the app never sees
156
+ it. `set -g mouse off` hands it back.
157
+
158
+ Turn the whole thing off with `--no-mouse`, `ARAGON_MOUSE=0`, or
159
+ `aragon config set mouse false`. With mouse support off the wheel simply does
160
+ nothing in full-screen mode — it does not fall back to editing your draft, which
161
+ is what it used to do. Inline mode is unaffected either way.
139
162
 
140
163
  ## Themes
141
164
 
package/dist/cli.js CHANGED
@@ -32,6 +32,7 @@ import { runSkillsCommand } from './skills/cli-commands.js';
32
32
  import { runHeadless } from './agent/headless.js';
33
33
  import { App } from './ui/App.js';
34
34
  import { decideRenderMode } from './ui/layout/frame.js';
35
+ import { tryCreateMouseFilter } from './input/stdin-mouse-filter.js';
35
36
  import { enterAltScreen, writeExitTranscript } from './ui/screen.js';
36
37
  import { readExitSnapshot } from './ui/exit-snapshot.js';
37
38
  import { renderTranscriptText } from './ui/transcript-text.js';
@@ -70,6 +71,9 @@ function toFlags(opts) {
70
71
  exitTranscript: opts.exitTranscript,
71
72
  compact: opts.compact,
72
73
  hints: opts.hints,
74
+ // Without this line the flag never reaches `CliFlags` and `--no-mouse` is
75
+ // inert — a failure with no compile error and no runtime error (P1-2).
76
+ mouse: opts.mouse,
73
77
  skills: opts.skills,
74
78
  skill: opts.skill,
75
79
  skillsYes: opts.skillsYes,
@@ -164,6 +168,31 @@ function runInteractive(flags, extras = {}) {
164
168
  attachAgentEvents(logger, controller);
165
169
  let screen = null;
166
170
  let replayed = false;
171
+ // --- Mouse wheel region routing (mouse-wheel-region-routing §4.2). -------
172
+ //
173
+ // BUILD THE FILTER FIRST, THEN ENABLE REPORTING. The order is invariant I-8
174
+ // and it is not negotiable: `\x1b[?1000h` tells the terminal to send SGR
175
+ // reports, and Ink 5.2.1 has no mouse branch at all, so any report that
176
+ // reaches its key parser is INSERTED INTO THE USER'S MESSAGE (`[` is 0x5B, so
177
+ // `PromptInput`'s `isControlSeq` guard passes it). A window in which
178
+ // reporting is on and Ink holds the raw stream is not a degraded feature — it
179
+ // is every click typing `[<0;12;5M` into the composer, strictly worse than
180
+ // the bug being fixed.
181
+ //
182
+ // `tryCreateMouseFilter` returns `null` rather than throwing, so a
183
+ // construction failure degrades to "the wheel is inert" (rung 3 of the
184
+ // fail-safe ladder), never to "reporting on, no filter".
185
+ // BOTH streams have to be terminals. `stdout.isTTY` is what decides whether
186
+ // `enterAltScreen` writes anything at all, but the reports come back on
187
+ // STDIN, so a TTY stdout with a piped stdin (`aragon config < file`, which
188
+ // reaches `runInteractive` without the stdin check the default action makes)
189
+ // would wrap a stream no report can ever arrive on and ask the terminal to
190
+ // start reporting anyway. §4.2's "non-TTY ⇒ no filter" covers both ends.
191
+ const wantMouse = mode === 'fullscreen' && config.mouse && !!process.stdout.isTTY && !!process.stdin.isTTY;
192
+ const mouseFilter = wantMouse
193
+ ? tryCreateMouseFilter(process.stdin, (reason) => logger.warn('cli', 'mouse_filter_failed', { reason }))
194
+ : null;
195
+ const disposeMouse = () => mouseFilter?.dispose();
167
196
  const replayTranscript = () => {
168
197
  if (replayed || mode !== 'fullscreen' || !config.exitTranscript)
169
198
  return;
@@ -181,7 +210,10 @@ function runInteractive(flags, extras = {}) {
181
210
  }));
182
211
  };
183
212
  if (mode === 'fullscreen') {
184
- screen = enterAltScreen(process.stdout);
213
+ // `mouse` here means "a filter is installed", NOT "the user wants mouse
214
+ // support" — see `AltScreenOptions`. `screen.ts` must never derive it for
215
+ // itself, or a later refactor re-opens the gap I-8 closes.
216
+ screen = enterAltScreen(process.stdout, { mouse: mouseFilter !== null });
185
217
  const restore = () => screen?.restore();
186
218
  // Four idempotent restore paths (§4.4). The signal hook is NOT redundant:
187
219
  // - `waitUntilExit().then()` is a microtask and may never be reached when
@@ -205,20 +237,38 @@ function runInteractive(flags, extras = {}) {
205
237
  process.exit(128 + signo);
206
238
  });
207
239
  }
208
- const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay }),
240
+ const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay, mouseSource: mouseFilter?.source }),
209
241
  // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
210
242
  // straight to stdout and permanently shifts the fixed frame's accounting.
211
- { exitOnCtrlC: false, patchConsole: mode === 'inline' });
243
+ {
244
+ exitOnCtrlC: false,
245
+ patchConsole: mode === 'inline',
246
+ // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
247
+ // every existing path is provably unchanged.
248
+ stdin: mouseFilter?.stdin ?? process.stdin,
249
+ });
212
250
  // The return value MUST be captured — `waitUntilExit()` is the normal-exit
213
- // restore path and it cannot be registered otherwise.
251
+ // restore path and it cannot be registered otherwise. `dispose()` runs on
252
+ // BOTH branches (I-7): a live `'data'` listener on stdin keeps the event loop
253
+ // alive and `aragon` would never return to the shell. The signal path does
254
+ // not need it — it calls `process.exit()`.
255
+ //
256
+ // RESTORE BEFORE DISPOSE. `restore()` writes `?1000l`, so the terminal stops
257
+ // reporting first and anything already in flight is still consumed by a live
258
+ // filter. Disposing first leaves a window — narrow, but real — in which the
259
+ // terminal is still reporting and nothing is draining stdin, so a notch taken
260
+ // in that instant survives in the OS buffer and is handed to the shell as
261
+ // `[<64;12;5M` on exit. That is the I-2 failure mode, just smaller.
214
262
  void instance
215
263
  .waitUntilExit()
216
264
  .then(() => {
217
265
  screen?.restore();
266
+ disposeMouse();
218
267
  replayTranscript();
219
268
  })
220
269
  .catch(() => {
221
270
  screen?.restore();
271
+ disposeMouse();
222
272
  });
223
273
  }
224
274
  async function runOneShot(flags, prompt, quiet) {
@@ -299,6 +349,11 @@ const CONFIG_SET_KEYS = new Set([
299
349
  // flags and never persist -- inconsistent with every other config field.
300
350
  'density',
301
351
  'hints',
352
+ // MEMBERSHIP HERE ONLY DECIDES THAT THE KEY IS NOT REJECTED. The `switch`
353
+ // below is what builds the patch, and a key present here but absent there
354
+ // falls through every case, writes nothing, and still prints
355
+ // `Set mouse = false` (P1-2) -- exactly what happened to `density`/`hints`.
356
+ 'mouse',
302
357
  'planModeDefault',
303
358
  'planModeMaxAskRounds',
304
359
  'planModeHumanTimeoutMs',
@@ -371,6 +426,9 @@ function runConfigSet(key, value) {
371
426
  case 'hints':
372
427
  patch.hints = value === 'true' || value === '1';
373
428
  break;
429
+ case 'mouse':
430
+ patch.mouse = value === 'true' || value === '1';
431
+ break;
374
432
  // Clamped rather than rejected, for the same reason as `theme` and
375
433
  // `skills.integrity`: hardening only the READ path leaves a bad value on
376
434
  // disk that reverts to the default on every launch, which presents as
@@ -452,6 +510,9 @@ function buildProgram() {
452
510
  .option('--no-compact', 'Comfortable transcript density')
453
511
  .option('--hints', 'Always show the composer hint row')
454
512
  .option('--no-hints', 'Hide the composer hint row')
513
+ // Positive form first, same tri-state reason as `--fullscreen` above.
514
+ .option('--mouse', 'Wheel scrolls the region under the pointer (full-screen mode)')
515
+ .option('--no-mouse', 'Leave the mouse to the terminal (the wheel does nothing)')
455
516
  // Declaring `--fullscreen` BEFORE `--no-fullscreen` keeps the default
456
517
  // `undefined` instead of `true`, which is what makes the tri-state work:
457
518
  // "no opinion" must stay distinguishable from "force it".
@@ -5,8 +5,8 @@
5
5
  * - Provider secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY /
6
6
  * GEMINI_API_KEY.
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
- * ARAGON_MAX_TOKENS, ARAGON_THEME, ARAGON_PLAN, ARAGON_LOG_LEVEL,
9
- * ARAGON_LOG_FILE, ARAGON_LOG_DIR.
8
+ * ARAGON_MAX_TOKENS, ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN,
9
+ * ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
10
  *
11
11
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
12
12
  * evaluated, which is long before `loadDotenv()` runs, so a `.env` can never
@@ -5,8 +5,8 @@
5
5
  * - Provider secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY /
6
6
  * GEMINI_API_KEY.
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
- * ARAGON_MAX_TOKENS, ARAGON_THEME, ARAGON_PLAN, ARAGON_LOG_LEVEL,
9
- * ARAGON_LOG_FILE, ARAGON_LOG_DIR.
8
+ * ARAGON_MAX_TOKENS, ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN,
9
+ * ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
10
  *
11
11
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
12
12
  * evaluated, which is long before `loadDotenv()` runs, so a `.env` can never
@@ -71,6 +71,14 @@ export function readEnvConfig() {
71
71
  if (fullscreen !== undefined && fullscreen.length > 0) {
72
72
  partial.fullscreen = fullscreen === '1' || fullscreen === 'true' || fullscreen === 'on' || fullscreen === 'yes';
73
73
  }
74
+ // ARAGON_MOUSE=0 turns off wheel region routing. Parsed by the POSITIVE list
75
+ // above, exactly as ARAGON_FULLSCREEN is — NOT by `envBool` below, which uses
76
+ // a negative list. The two disagree on `ARAGON_MOUSE=disable`, and `mouse` is
77
+ // modelled on `--fullscreen`, so it uses the reader `--fullscreen` uses.
78
+ const mouse = process.env.ARAGON_MOUSE?.trim().toLowerCase();
79
+ if (mouse !== undefined && mouse.length > 0) {
80
+ partial.mouse = mouse === '1' || mouse === 'true' || mouse === 'on' || mouse === 'yes';
81
+ }
74
82
  // ARAGON_PLAN=1 starts the session in PLAN mode — the same channel story as
75
83
  // ARAGON_FULLSCREEN, for terminals reached through a wrapper that owns argv.
76
84
  const plan = process.env.ARAGON_PLAN?.trim().toLowerCase();
@@ -27,6 +27,8 @@ export interface CliFlags {
27
27
  compact?: boolean;
28
28
  /** `--hints` ⇒ true, `--no-hints` ⇒ false, absent ⇒ undefined. */
29
29
  hints?: boolean;
30
+ /** `--mouse` ⇒ true, `--no-mouse` ⇒ false, absent ⇒ undefined. */
31
+ mouse?: boolean;
30
32
  /** `--no-skills` ⇒ false. Turns the whole skill subsystem off (§7.5). */
31
33
  skills?: boolean;
32
34
  /** `--skill <name>` ×N — force Level 2 injection for this run. */
@@ -33,6 +33,28 @@ function resolveFullscreen(flags, env, file) {
33
33
  return false;
34
34
  return undefined;
35
35
  }
36
+ /**
37
+ * Resolve wheel region routing: flag › env › file › default `true`.
38
+ *
39
+ * THIS NEEDS ITS OWN RESOLVER; it does not "match every other boolean" in this
40
+ * file. `hints` is `flags.hints !== undefined ? … : file.hints ?? DEFAULT` and
41
+ * reads NO ENV AT ALL — copying that line would ship `ARAGON_MOUSE` as
42
+ * documented-but-dead (P1-9). Only `fullscreen` has an env layer, and only
43
+ * because `resolveFullscreen` above was written to give it one.
44
+ *
45
+ * Unlike `fullscreen` this is a plain boolean rather than a tri-state: there
46
+ * are no heuristics for an explicit `true` to override, so a persisted `true`
47
+ * is just a value and not an opinion that has to be told apart from silence.
48
+ */
49
+ function resolveMouse(flags, env, file) {
50
+ if (flags.mouse !== undefined)
51
+ return flags.mouse;
52
+ if (env.mouse !== undefined)
53
+ return env.mouse;
54
+ if (file.mouse !== undefined)
55
+ return file.mouse;
56
+ return DEFAULT_CONFIG.mouse;
57
+ }
36
58
  /**
37
59
  * Resolve whether logs reach a file: flag › env › an explicit `false` in the
38
60
  * file › default `true`.
@@ -177,6 +199,7 @@ export function loadConfig(flags = {}) {
177
199
  promptHistory: file.promptHistory ?? [],
178
200
  density,
179
201
  hints,
202
+ mouse: resolveMouse(flags, env.partial, file),
180
203
  submitCount: coercePositiveInt(file.submitCount, 0),
181
204
  startInPlanMode,
182
205
  planModeMaxAskRounds,
@@ -217,6 +217,21 @@ export interface PersistedConfig {
217
217
  density: DensityMode;
218
218
  /** Show the composer hint row at all. */
219
219
  hints: boolean;
220
+ /**
221
+ * Enable SGR mouse reporting in full-screen mode so the wheel scrolls the
222
+ * region under the pointer (mouse-wheel-region-routing §5.7).
223
+ *
224
+ * A PLAIN BOOLEAN, not a `fullscreen`-style tri-state: there are no
225
+ * heuristics for an explicit `true` to override (D-10).
226
+ */
227
+ mouse: boolean;
228
+ /**
229
+ * Whether the one-time "hold Shift to select text" notice has been shown
230
+ * (§6.1). Bookkeeping for a one-shot notice, NOT a preference — hence no
231
+ * flag, no env var and no `config set` key. Giving it a user-facing channel
232
+ * would invite it to be treated as one.
233
+ */
234
+ mouseNoticeSeen: boolean;
220
235
  /** Lifetime submit count driving the progressive-disclosure fade (§4.6). */
221
236
  submitCount: number;
222
237
  /** Start every session in PLAN mode (plan-mode §4.4). */
@@ -263,6 +278,11 @@ export interface CliConfig {
263
278
  promptHistory: string[];
264
279
  density: DensityMode;
265
280
  hints: boolean;
281
+ /**
282
+ * Whether this session may enable SGR mouse reporting (full-screen + TTY
283
+ * only). Resolved by `resolveMouse` in `load.ts`.
284
+ */
285
+ mouse: boolean;
266
286
  submitCount: number;
267
287
  /**
268
288
  * Whether this session opens in PLAN mode.
@@ -280,6 +280,8 @@ export const DEFAULT_CONFIG = {
280
280
  promptHistory: [],
281
281
  density: 'comfortable',
282
282
  hints: true,
283
+ mouse: true,
284
+ mouseNoticeSeen: false,
283
285
  submitCount: 0,
284
286
  planModeDefault: false,
285
287
  planModeMaxAskRounds: DEFAULT_PLAN_MAX_ASK_ROUNDS,
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Mouse-report parser (mouse-wheel-region-routing §5.1) — pure, stream-free.
3
+ *
4
+ * The alternate screen has no native scrollback, so every modern terminal turns
5
+ * on DEC private mode 1007 (*alternate scroll*) and translates each wheel notch
6
+ * into a burst of arrow keys. Arrow keys carry NO POINTER POSITION, which is
7
+ * why region routing is impossible from that channel and why the CLI asks for
8
+ * real SGR mouse reporting instead (`\x1b[?1000h\x1b[?1006h`).
9
+ *
10
+ * Asking for it is only half the job. Ink 5.2.1 has no mouse branch at all:
11
+ * `parse-keypress.js` fails to match `\x1b[<64;40;12M`, `use-input.js` strips
12
+ * the leading ESC, and `PromptInput`'s `isControlSeq` guard passes the
13
+ * remainder (`[` is 0x5B, >= 0x20) straight into `insert()`. So an unfiltered
14
+ * mouse report is not ignored — it is TYPED INTO THE USER'S MESSAGE. This
15
+ * module exists so `stdin-mouse-filter.ts` can remove every byte of every
16
+ * report before Ink ever sees the chunk (invariant I-1).
17
+ *
18
+ * Non-wheel reports (press / release / drag) are recognized and DISCARDED
19
+ * rather than passed through, for the same reason.
20
+ */
21
+ export interface WheelEvent {
22
+ readonly kind: 'wheel';
23
+ readonly dir: 'up' | 'down';
24
+ /** 1-based terminal column, as reported. */
25
+ readonly x: number;
26
+ /** 1-based terminal row, as reported. */
27
+ readonly y: number;
28
+ readonly shift: boolean;
29
+ readonly alt: boolean;
30
+ readonly ctrl: boolean;
31
+ }
32
+ export interface MouseSplit {
33
+ /** Wheel events, in arrival order. Non-wheel reports are dropped, not returned. */
34
+ readonly events: WheelEvent[];
35
+ /** Everything that was not a mouse report — forward this to Ink verbatim. */
36
+ readonly text: string;
37
+ /** Trailing strict prefix of a possible report; feed it back in with the next chunk. */
38
+ readonly pending: string;
39
+ }
40
+ /**
41
+ * Ceiling on the held-back tail. A malformed sequence that looks like a prefix
42
+ * forever must never be able to wedge input: past this length the tail is
43
+ * flushed as ordinary text and parsing resumes from the next byte.
44
+ */
45
+ export declare const MAX_PENDING_MOUSE_CHARS = 32;
46
+ /**
47
+ * True when `s` is a PROPER prefix of a possible mouse report (never a full
48
+ * one). Nothing a keyboard produces matches beyond `\x1b` and `\x1b[`, which is
49
+ * why the filter's flush timer is a rare path rather than a latency tax on
50
+ * typing: ordinary text and pasted blocks contain no `\x1b[<`.
51
+ */
52
+ export declare function isMousePrefix(s: string): boolean;
53
+ /**
54
+ * Split a decoded stdin chunk into wheel events, passthrough text, and a
55
+ * trailing prefix to carry into the next chunk.
56
+ *
57
+ * The caller is expected to feed `pending + nextChunk` back in; ConPTY does
58
+ * split writes mid-sequence, and a report torn across two chunks would
59
+ * otherwise leak its tail into the composer (R-6).
60
+ */
61
+ export declare function splitMouseEvents(buffer: string): MouseSplit;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Mouse-report parser (mouse-wheel-region-routing §5.1) — pure, stream-free.
3
+ *
4
+ * The alternate screen has no native scrollback, so every modern terminal turns
5
+ * on DEC private mode 1007 (*alternate scroll*) and translates each wheel notch
6
+ * into a burst of arrow keys. Arrow keys carry NO POINTER POSITION, which is
7
+ * why region routing is impossible from that channel and why the CLI asks for
8
+ * real SGR mouse reporting instead (`\x1b[?1000h\x1b[?1006h`).
9
+ *
10
+ * Asking for it is only half the job. Ink 5.2.1 has no mouse branch at all:
11
+ * `parse-keypress.js` fails to match `\x1b[<64;40;12M`, `use-input.js` strips
12
+ * the leading ESC, and `PromptInput`'s `isControlSeq` guard passes the
13
+ * remainder (`[` is 0x5B, >= 0x20) straight into `insert()`. So an unfiltered
14
+ * mouse report is not ignored — it is TYPED INTO THE USER'S MESSAGE. This
15
+ * module exists so `stdin-mouse-filter.ts` can remove every byte of every
16
+ * report before Ink ever sees the chunk (invariant I-1).
17
+ *
18
+ * Non-wheel reports (press / release / drag) are recognized and DISCARDED
19
+ * rather than passed through, for the same reason.
20
+ */
21
+ /**
22
+ * Ceiling on the held-back tail. A malformed sequence that looks like a prefix
23
+ * forever must never be able to wedge input: past this length the tail is
24
+ * flushed as ordinary text and parsing resumes from the next byte.
25
+ */
26
+ export const MAX_PENDING_MOUSE_CHARS = 32;
27
+ const ESC = '\x1b';
28
+ /** `\x1b[<b;x;yM` (press) or `…m` (release) — the only form we request. */
29
+ const SGR_REPORT = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
30
+ /** A strict prefix of an SGR report: `\x1b[<` plus digits and semicolons. */
31
+ const SGR_PREFIX = /^\x1b\[<[\d;]*$/;
32
+ /**
33
+ * True when `s` is a PROPER prefix of a possible mouse report (never a full
34
+ * one). Nothing a keyboard produces matches beyond `\x1b` and `\x1b[`, which is
35
+ * why the filter's flush timer is a rare path rather than a latency tax on
36
+ * typing: ordinary text and pasted blocks contain no `\x1b[<`.
37
+ */
38
+ export function isMousePrefix(s) {
39
+ return /^\x1B(\[(<[\d;]*|M[\s\S]{0,2})?)?$/.test(s);
40
+ }
41
+ /**
42
+ * Decode an SGR button byte into a wheel event, or `null` when the report is
43
+ * not a wheel notch we act on.
44
+ *
45
+ * THE ORDER OF THESE TESTS IS THE SPECIFICATION, not a presentation choice.
46
+ * Button 66 (wheel-left) has `b & 1 === 0` and would decode as `'up'`, so the
47
+ * horizontal test has to precede the direction decode.
48
+ */
49
+ function decodeWheel(b, x, y, final) {
50
+ // 1. Not a wheel report at all (press / release / drag) — consume, emit nothing.
51
+ if ((b & 64) === 0)
52
+ return null;
53
+ // 2. Horizontal wheel (66 = left, 67 = right). Consumed and dropped (non-goal).
54
+ if ((b & 3) >= 2)
55
+ return null;
56
+ // 3. Wheel buttons are press-only; a terminal that ever sent a release for one
57
+ // must not produce a second step for the same physical notch.
58
+ if (final !== 'M')
59
+ return null;
60
+ return {
61
+ kind: 'wheel',
62
+ dir: (b & 1) === 1 ? 'down' : 'up',
63
+ x,
64
+ y,
65
+ shift: (b & 4) !== 0,
66
+ alt: (b & 8) !== 0,
67
+ ctrl: (b & 16) !== 0,
68
+ };
69
+ }
70
+ /**
71
+ * X10 legacy reports (`\x1b[M` + three chars, each `code - 32`).
72
+ *
73
+ * We never request this encoding — `?1006h` is over a decade old and every
74
+ * terminal that implements `?1000` implements it — but a terminal that ignored
75
+ * the SGR request would fall back to it, and a leaked `\x1b[M` + garbage is
76
+ * exactly the draft corruption I-1 exists to prevent. So it is consumed
77
+ * unconditionally; the coordinates are only trusted when all three bytes are
78
+ * plausible, because `setEncoding('utf8')` mangles raw bytes above 0x7F.
79
+ */
80
+ function matchX10(s) {
81
+ if (s.length < 6)
82
+ return { kind: 'partial' };
83
+ const codes = [s.charCodeAt(3), s.charCodeAt(4), s.charCodeAt(5)];
84
+ if (codes.some((c) => c < 32 || c > 255))
85
+ return { kind: 'complete', length: 6, event: null };
86
+ const [b, x, y] = codes;
87
+ return { kind: 'complete', length: 6, event: decodeWheel(b - 32, x - 32, y - 32, 'M') };
88
+ }
89
+ /** Classify the escape sequence starting at `s[0] === ESC`. */
90
+ function matchReport(s) {
91
+ if (s.length === 1)
92
+ return { kind: 'partial' }; // lone ESC so far
93
+ if (s[1] !== '[')
94
+ return { kind: 'none' };
95
+ if (s.length === 2)
96
+ return { kind: 'partial' }; // `\x1b[`
97
+ if (s[2] === '<') {
98
+ const m = SGR_REPORT.exec(s);
99
+ if (m) {
100
+ const b = Number(m[1]);
101
+ const x = Number(m[2]);
102
+ const y = Number(m[3]);
103
+ return { kind: 'complete', length: m[0].length, event: decodeWheel(b, x, y, m[4]) };
104
+ }
105
+ return SGR_PREFIX.test(s) ? { kind: 'partial' } : { kind: 'none' };
106
+ }
107
+ if (s[2] === 'M')
108
+ return matchX10(s);
109
+ return { kind: 'none' };
110
+ }
111
+ /**
112
+ * Split a decoded stdin chunk into wheel events, passthrough text, and a
113
+ * trailing prefix to carry into the next chunk.
114
+ *
115
+ * The caller is expected to feed `pending + nextChunk` back in; ConPTY does
116
+ * split writes mid-sequence, and a report torn across two chunks would
117
+ * otherwise leak its tail into the composer (R-6).
118
+ */
119
+ export function splitMouseEvents(buffer) {
120
+ const events = [];
121
+ let text = '';
122
+ let i = 0;
123
+ while (i < buffer.length) {
124
+ const esc = buffer.indexOf(ESC, i);
125
+ if (esc === -1) {
126
+ text += buffer.slice(i);
127
+ return { events, text, pending: '' };
128
+ }
129
+ text += buffer.slice(i, esc);
130
+ const rest = buffer.slice(esc);
131
+ const m = matchReport(rest);
132
+ if (m.kind === 'complete') {
133
+ if (m.event)
134
+ events.push(m.event);
135
+ i = esc + m.length;
136
+ continue;
137
+ }
138
+ if (m.kind === 'partial') {
139
+ // A tail that never resolves must not hold input hostage: past the
140
+ // ceiling it is ordinary text, and parsing continues after the ESC.
141
+ if (rest.length > MAX_PENDING_MOUSE_CHARS) {
142
+ text += rest;
143
+ return { events, text, pending: '' };
144
+ }
145
+ return { events, text, pending: rest };
146
+ }
147
+ // Not a mouse report — an arrow key, a paste, anything. Emit the ESC and
148
+ // resume scanning after it so the rest of the sequence reaches Ink intact.
149
+ text += ESC;
150
+ i = esc + 1;
151
+ }
152
+ return { events, text, pending: '' };
153
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Stdin mouse filter (mouse-wheel-region-routing §4.2 / §5.2).
3
+ *
4
+ * WHY A WRAPPER AND NOT A SECOND LISTENER. Ink 5.2.1 owns stdin in a way that
5
+ * forbids a second reader: `components/App.js` calls `setEncoding('utf8')`,
6
+ * `ref()`, `setRawMode(true)`, subscribes to `'readable'` and drains with
7
+ * `read()`. Attaching our own `'data'` listener to the same `process.stdin`
8
+ * would switch the stream into flowing mode and RACE Ink's `read()` for chunks
9
+ * — some keystrokes would reach Ink, some would not, non-deterministically
10
+ * (D-3). So the filter REPLACES the stream Ink is given: it reads the real
11
+ * stdin, strips every mouse report, and writes the remainder into a
12
+ * `PassThrough` that carries the surface Ink requires.
13
+ *
14
+ * ORDERING IS AN INVARIANT, NOT A CONVENTION (I-8). The caller must build this
15
+ * filter BEFORE anything writes `\x1b[?1000h`. Reporting enabled while Ink
16
+ * still holds the raw stream does not degrade the feature — it types
17
+ * `[<0;12;5M` into the user's message on every click, which is strictly worse
18
+ * than the bug this feature fixes. `cli.tsx::runInteractive` is the only caller
19
+ * and passes `filter !== null` to `enterAltScreen`.
20
+ */
21
+ import { type WheelEvent } from './mouse-events.js';
22
+ export interface MouseSource {
23
+ /** Returns an unsubscribe function. Multiple subscribers are allowed. */
24
+ subscribe(listener: (event: WheelEvent) => void): () => void;
25
+ }
26
+ export interface MouseFilter {
27
+ /** Hand this to Ink: `render(<App/>, { stdin: filter.stdin })`. */
28
+ readonly stdin: NodeJS.ReadStream;
29
+ readonly source: MouseSource;
30
+ /** Idempotent. Detaches from the real stdin and clears the flush timer (I-7). */
31
+ dispose(): void;
32
+ }
33
+ export declare function createMouseFilter(real: NodeJS.ReadStream): MouseFilter;
34
+ /**
35
+ * Build a filter, or `null` if construction throws.
36
+ *
37
+ * The `null` return is the whole point (P0-1): `enterAltScreen` derives whether
38
+ * to enable mouse reporting from `filter !== null`, so a construction failure
39
+ * degrades to "the wheel is inert" — a supported, tested rung of the fail-safe
40
+ * ladder — rather than leaving reporting on with no filter in front of Ink.
41
+ */
42
+ export declare function tryCreateMouseFilter(real: NodeJS.ReadStream, onError?: (reason: string) => void): MouseFilter | null;