@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.
- package/CHANGELOG.md +27 -0
- package/README.md +27 -4
- package/dist/cli.js +65 -4
- package/dist/config/env.d.ts +2 -2
- package/dist/config/env.js +10 -2
- package/dist/config/load.d.ts +2 -0
- package/dist/config/load.js +23 -0
- package/dist/config/schema.d.ts +20 -0
- package/dist/config/schema.js +2 -0
- package/dist/input/mouse-events.d.ts +61 -0
- package/dist/input/mouse-events.js +153 -0
- package/dist/input/stdin-mouse-filter.d.ts +42 -0
- package/dist/input/stdin-mouse-filter.js +153 -0
- package/dist/ui/App.d.ts +9 -1
- package/dist/ui/App.js +45 -10
- package/dist/ui/Composer.d.ts +6 -1
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/PromptInput.d.ts +15 -1
- package/dist/ui/PromptInput.js +38 -1
- package/dist/ui/glyphs.d.ts +12 -0
- package/dist/ui/glyphs.js +4 -0
- package/dist/ui/layout/AppShell.d.ts +10 -1
- package/dist/ui/layout/AppShell.js +38 -3
- package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
- package/dist/ui/layout/ScrollIndicator.js +15 -0
- package/dist/ui/layout/ScrollViewport.d.ts +8 -2
- package/dist/ui/layout/ScrollViewport.js +24 -4
- package/dist/ui/layout/regions.d.ts +55 -0
- package/dist/ui/layout/regions.js +52 -0
- package/dist/ui/layout/scroll-indicator.d.ts +27 -0
- package/dist/ui/layout/scroll-indicator.js +35 -0
- package/dist/ui/layout/scroll.d.ts +9 -0
- package/dist/ui/layout/scroll.js +23 -0
- package/dist/ui/overlays/HelpOverlay.js +2 -0
- package/dist/ui/screen.d.ts +14 -1
- package/dist/ui/screen.js +37 -3
- package/dist/ui/use-startup-notices.d.ts +11 -1
- package/dist/ui/use-startup-notices.js +28 -2
- package/dist/ui/use-wheel-routing.d.ts +68 -0
- package/dist/ui/use-wheel-routing.js +139 -0
- 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
|
|
136
|
-
|
|
137
|
-
newest output. In **inline mode**
|
|
138
|
-
terminal's native scrollback, printed
|
|
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
|
-
|
|
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
|
-
{
|
|
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".
|
package/dist/config/env.d.ts
CHANGED
|
@@ -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,
|
|
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
|
package/dist/config/env.js
CHANGED
|
@@ -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,
|
|
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();
|
package/dist/config/load.d.ts
CHANGED
|
@@ -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. */
|
package/dist/config/load.js
CHANGED
|
@@ -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,
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -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.
|
package/dist/config/schema.js
CHANGED
|
@@ -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;
|