@aragon-agent/cli 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/README.md +155 -5
- package/dist/agent/controller.d.ts +51 -1
- package/dist/agent/controller.js +177 -2
- package/dist/agent/headless.d.ts +12 -0
- package/dist/agent/headless.js +41 -1
- package/dist/agent/reducer.d.ts +48 -0
- package/dist/agent/reducer.js +91 -2
- package/dist/agent/system-prompt.d.ts +17 -0
- package/dist/agent/system-prompt.js +4 -0
- package/dist/cli.js +131 -6
- package/dist/commands/builtins.js +82 -0
- package/dist/config/app-paths.d.ts +17 -0
- package/dist/config/app-paths.js +21 -0
- package/dist/config/cli-commands.d.ts +26 -2
- package/dist/config/cli-commands.js +63 -4
- package/dist/config/env.d.ts +2 -2
- package/dist/config/env.js +36 -2
- package/dist/config/history-commands.d.ts +20 -0
- package/dist/config/history-commands.js +71 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/config/load.js +88 -5
- package/dist/config/migrate-state-out-of-config.d.ts +32 -0
- package/dist/config/migrate-state-out-of-config.js +85 -0
- package/dist/config/prompt-history.d.ts +86 -0
- package/dist/config/prompt-history.js +332 -0
- package/dist/config/schema.d.ts +125 -9
- package/dist/config/schema.js +90 -5
- package/dist/config/store.js +13 -4
- package/dist/config/ui-state.d.ts +52 -0
- package/dist/config/ui-state.js +149 -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/logging/install.d.ts +85 -0
- package/dist/logging/install.js +94 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/persist.d.ts +16 -0
- package/dist/session/persist.js +19 -1
- package/dist/team/activity.d.ts +67 -0
- package/dist/team/activity.js +130 -0
- package/dist/team/bus.d.ts +190 -0
- package/dist/team/bus.js +366 -0
- package/dist/team/comm-tools.d.ts +61 -0
- package/dist/team/comm-tools.js +221 -0
- package/dist/team/human-queue.d.ts +55 -0
- package/dist/team/human-queue.js +72 -0
- package/dist/team/limits.d.ts +123 -0
- package/dist/team/limits.js +128 -0
- package/dist/team/normalize.d.ts +39 -0
- package/dist/team/normalize.js +106 -0
- package/dist/team/panel-rows.d.ts +48 -0
- package/dist/team/panel-rows.js +75 -0
- package/dist/team/prompt.d.ts +49 -0
- package/dist/team/prompt.js +87 -0
- package/dist/team/report.d.ts +58 -0
- package/dist/team/report.js +205 -0
- package/dist/team/retry.d.ts +48 -0
- package/dist/team/retry.js +56 -0
- package/dist/team/runtime.d.ts +122 -0
- package/dist/team/runtime.js +406 -0
- package/dist/team/subagent.d.ts +90 -0
- package/dist/team/subagent.js +294 -0
- package/dist/team/task-tool.d.ts +47 -0
- package/dist/team/task-tool.js +118 -0
- package/dist/team/types.d.ts +172 -0
- package/dist/team/types.js +13 -0
- package/dist/tools/index.d.ts +48 -2
- package/dist/tools/index.js +26 -1
- package/dist/ui/App.d.ts +9 -1
- package/dist/ui/App.js +140 -21
- 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/StatusBar.d.ts +21 -0
- package/dist/ui/StatusBar.js +5 -2
- package/dist/ui/TeamPanel.d.ts +57 -0
- package/dist/ui/TeamPanel.js +134 -0
- package/dist/ui/Transcript.js +10 -0
- package/dist/ui/entries/TeamCard.d.ts +31 -0
- package/dist/ui/entries/TeamCard.js +39 -0
- package/dist/ui/glyphs.d.ts +22 -0
- package/dist/ui/glyphs.js +10 -0
- package/dist/ui/layout/AppShell.d.ts +20 -1
- package/dist/ui/layout/AppShell.js +39 -4
- 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 +14 -0
- package/dist/ui/screen.d.ts +14 -1
- package/dist/ui/screen.js +37 -3
- package/dist/ui/transcript-text.js +20 -0
- package/dist/ui/use-startup-notices.d.ts +11 -1
- package/dist/ui/use-startup-notices.js +29 -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/dist/config/store.js
CHANGED
|
@@ -14,7 +14,7 @@ import { dirname } from 'node:path';
|
|
|
14
14
|
import process from 'node:process';
|
|
15
15
|
import { getConfigPath, getSessionsDir } from './app-paths.js';
|
|
16
16
|
import { registerSecretsFrom } from '../logging/secret-registry.js';
|
|
17
|
-
import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampSkillsConfig, } from './schema.js';
|
|
17
|
+
import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampSkillsConfig, clampTeamConfig, stripLegacyStateKeys, } from './schema.js';
|
|
18
18
|
// Re-exported so the many existing importers of `store.js` keep working; the
|
|
19
19
|
// definitions live in `app-paths.ts` because they are pure path arithmetic.
|
|
20
20
|
export { getConfigPath, getSessionsDir };
|
|
@@ -82,19 +82,24 @@ export function writeConfigFile(config) {
|
|
|
82
82
|
* Unlike `readConfigFile`, this always returns a complete `PersistedConfig`.
|
|
83
83
|
*/
|
|
84
84
|
export function loadPersistedConfig() {
|
|
85
|
-
|
|
85
|
+
// STRIP BEFORE THE SPREAD, not after. `partial` is the raw file, so any
|
|
86
|
+
// `promptHistory` / `submitCount` / `mouseNoticeSeen` / `recentModels` left
|
|
87
|
+
// on disk would otherwise ride the spread into `merged` and be written back
|
|
88
|
+
// out by `updatePersistedConfig` — the "I deleted it and it grew back" bug
|
|
89
|
+
// that `stripLegacyStateKeys` exists to make impossible (§4.5).
|
|
90
|
+
const partial = stripLegacyStateKeys(readConfigFile().config ?? {});
|
|
86
91
|
return {
|
|
87
92
|
...DEFAULT_CONFIG,
|
|
88
93
|
...partial,
|
|
89
94
|
version: CONFIG_VERSION,
|
|
90
95
|
apiKeys: { ...DEFAULT_CONFIG.apiKeys, ...(partial.apiKeys ?? {}) },
|
|
91
|
-
recentModels: partial.recentModels ?? [],
|
|
92
|
-
promptHistory: partial.promptHistory ?? [],
|
|
93
96
|
// `skills` is the second nested object in this file and needs the same
|
|
94
97
|
// treatment `apiKeys` gets — see the note on updatePersistedConfig below.
|
|
95
98
|
skills: clampSkillsConfig({ ...DEFAULT_CONFIG.skills, ...(partial.skills ?? {}) }),
|
|
96
99
|
// `log` is the third. Same one-level rule, same merge.
|
|
97
100
|
log: clampLogConfig({ ...DEFAULT_CONFIG.log, ...(partial.log ?? {}) }),
|
|
101
|
+
// `team` is the fourth. Same one-level rule, same merge.
|
|
102
|
+
team: clampTeamConfig({ ...DEFAULT_CONFIG.team, ...(partial.team ?? {}) }),
|
|
98
103
|
};
|
|
99
104
|
}
|
|
100
105
|
/**
|
|
@@ -132,6 +137,10 @@ export function updatePersistedConfig(patch) {
|
|
|
132
137
|
// replace the whole section and quietly revert `maxFiles`, `previewChars`
|
|
133
138
|
// and — worst of all — `redactSecrets`.
|
|
134
139
|
log: clampLogConfig({ ...current.log, ...(patch.log ?? {}) }),
|
|
140
|
+
// The fourth section, and it needs the merge for the same reason: `/team max
|
|
141
|
+
// 4` sends `{ team: { maxSubagents: 4 } }`, which a shallow spread would
|
|
142
|
+
// turn into "changing the fan-out width silently reverted every timeout".
|
|
143
|
+
team: clampTeamConfig({ ...current.team, ...(patch.team ?? {}) }),
|
|
135
144
|
};
|
|
136
145
|
writeConfigFile(merged);
|
|
137
146
|
return merged;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<home>/state.json` — UI bookkeeping (config-state-separation §4.4).
|
|
3
|
+
*
|
|
4
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* WHAT MAY LIVE HERE: UI bookkeeping SCALARS. Counters, and "has this one-shot
|
|
6
|
+
* notice been shown" flags. NOTHING ELSE.
|
|
7
|
+
*
|
|
8
|
+
* Anything carrying user content, and anything that grows with usage, opens its
|
|
9
|
+
* own file. This rule is not a preference — it is the entire reason this module
|
|
10
|
+
* exists. `config.json` became a place where a prompt-by-prompt transcript sat
|
|
11
|
+
* next to the API key precisely because nobody had written a rule like this
|
|
12
|
+
* down, and a file called `state.json` is the most inviting drawer in the
|
|
13
|
+
* codebase (Q-4 / RV-8).
|
|
14
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
*
|
|
16
|
+
* Nothing here is a preference: none of these fields has a flag, an env var or
|
|
17
|
+
* a `config set` key, and none of them should ever get one. Losing the file
|
|
18
|
+
* costs a composer hint reappearing once.
|
|
19
|
+
*
|
|
20
|
+
* FAILURE POLICY (C2): never throws, exactly like `prompt-history.ts` and
|
|
21
|
+
* `skills/usage.ts`. Writes are immediate rather than debounced (D-7) — the
|
|
22
|
+
* file is one small object, and a debounce would add a flush-on-exit obligation
|
|
23
|
+
* whose failure mode is the counter silently not sticking.
|
|
24
|
+
*
|
|
25
|
+
* NOT the same thing as `migrate-legacy-state.ts`, despite the shared word:
|
|
26
|
+
* that one is the 0.4.x legacy-to-current brand migration and has nothing to do
|
|
27
|
+
* with this file.
|
|
28
|
+
*/
|
|
29
|
+
export declare const UI_STATE_FILENAME = "state.json";
|
|
30
|
+
export declare const UI_STATE_SCHEMA = 1;
|
|
31
|
+
export interface UiState {
|
|
32
|
+
schema: number;
|
|
33
|
+
/** Lifetime submit count; drives the composer hint fade (`HINT_FADE_AFTER`). */
|
|
34
|
+
submitCount: number;
|
|
35
|
+
/** Whether the one-shot "hold Shift to select text" notice has been shown. */
|
|
36
|
+
mouseNoticeSeen: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** Read the state, caching it for the life of the process. Never throws. */
|
|
39
|
+
export declare function loadUiState(): UiState;
|
|
40
|
+
export declare function readSubmitCount(): number;
|
|
41
|
+
/** Increment, persist, and return the new value. */
|
|
42
|
+
export declare function bumpSubmitCount(): number;
|
|
43
|
+
export declare function getMouseNoticeSeen(): boolean;
|
|
44
|
+
export declare function setMouseNoticeSeen(seen: boolean): void;
|
|
45
|
+
/**
|
|
46
|
+
* Migration-only: fill in fields that are still at their default, and never
|
|
47
|
+
* overwrite a value already here. A second import — after a half-finished
|
|
48
|
+
* migration retries — must not walk the counter backwards.
|
|
49
|
+
*/
|
|
50
|
+
export declare function importLegacyUiState(patch: Partial<Pick<UiState, 'submitCount' | 'mouseNoticeSeen'>>): void;
|
|
51
|
+
/** Test-only: forget the cached state. */
|
|
52
|
+
export declare function resetUiStateForTests(): void;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<home>/state.json` — UI bookkeeping (config-state-separation §4.4).
|
|
3
|
+
*
|
|
4
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* WHAT MAY LIVE HERE: UI bookkeeping SCALARS. Counters, and "has this one-shot
|
|
6
|
+
* notice been shown" flags. NOTHING ELSE.
|
|
7
|
+
*
|
|
8
|
+
* Anything carrying user content, and anything that grows with usage, opens its
|
|
9
|
+
* own file. This rule is not a preference — it is the entire reason this module
|
|
10
|
+
* exists. `config.json` became a place where a prompt-by-prompt transcript sat
|
|
11
|
+
* next to the API key precisely because nobody had written a rule like this
|
|
12
|
+
* down, and a file called `state.json` is the most inviting drawer in the
|
|
13
|
+
* codebase (Q-4 / RV-8).
|
|
14
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
*
|
|
16
|
+
* Nothing here is a preference: none of these fields has a flag, an env var or
|
|
17
|
+
* a `config set` key, and none of them should ever get one. Losing the file
|
|
18
|
+
* costs a composer hint reappearing once.
|
|
19
|
+
*
|
|
20
|
+
* FAILURE POLICY (C2): never throws, exactly like `prompt-history.ts` and
|
|
21
|
+
* `skills/usage.ts`. Writes are immediate rather than debounced (D-7) — the
|
|
22
|
+
* file is one small object, and a debounce would add a flush-on-exit obligation
|
|
23
|
+
* whose failure mode is the counter silently not sticking.
|
|
24
|
+
*
|
|
25
|
+
* NOT the same thing as `migrate-legacy-state.ts`, despite the shared word:
|
|
26
|
+
* that one is the 0.4.x legacy-to-current brand migration and has nothing to do
|
|
27
|
+
* with this file.
|
|
28
|
+
*/
|
|
29
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
30
|
+
import process from 'node:process';
|
|
31
|
+
import { getHomeRoot, getUiStatePath } from './app-paths.js';
|
|
32
|
+
export const UI_STATE_FILENAME = 'state.json';
|
|
33
|
+
export const UI_STATE_SCHEMA = 1;
|
|
34
|
+
const DEFAULT_UI_STATE = {
|
|
35
|
+
schema: UI_STATE_SCHEMA,
|
|
36
|
+
submitCount: 0,
|
|
37
|
+
mouseNoticeSeen: false,
|
|
38
|
+
};
|
|
39
|
+
let memory = null;
|
|
40
|
+
function coerceCount(value) {
|
|
41
|
+
const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
|
|
42
|
+
if (!Number.isFinite(n) || n < 0)
|
|
43
|
+
return DEFAULT_UI_STATE.submitCount;
|
|
44
|
+
return Math.floor(n);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Per-field tolerance: one nonsense value must not take the other field with
|
|
48
|
+
* it. A wrong SCHEMA is different — the shape is no longer known, so the whole
|
|
49
|
+
* object falls back (the rule `skills/usage.ts` already follows).
|
|
50
|
+
*/
|
|
51
|
+
function readFromDisk() {
|
|
52
|
+
const path = getUiStatePath();
|
|
53
|
+
if (!existsSync(path))
|
|
54
|
+
return { ...DEFAULT_UI_STATE };
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
57
|
+
if (!parsed || typeof parsed !== 'object')
|
|
58
|
+
return { ...DEFAULT_UI_STATE };
|
|
59
|
+
if (parsed.schema !== UI_STATE_SCHEMA)
|
|
60
|
+
return { ...DEFAULT_UI_STATE };
|
|
61
|
+
return {
|
|
62
|
+
schema: UI_STATE_SCHEMA,
|
|
63
|
+
submitCount: coerceCount(parsed.submitCount),
|
|
64
|
+
mouseNoticeSeen: typeof parsed.mouseNoticeSeen === 'boolean'
|
|
65
|
+
? parsed.mouseNoticeSeen
|
|
66
|
+
: DEFAULT_UI_STATE.mouseNoticeSeen,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { ...DEFAULT_UI_STATE };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Read the state, caching it for the life of the process. Never throws. */
|
|
74
|
+
export function loadUiState() {
|
|
75
|
+
if (memory === null)
|
|
76
|
+
memory = readFromDisk();
|
|
77
|
+
return memory;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Atomic write: temp file, rename, re-chmod.
|
|
81
|
+
*
|
|
82
|
+
* `mkdirSync` first for the reason spelled out in `prompt-history.ts`: `<home>`
|
|
83
|
+
* is not guaranteed to exist, and without it the counter would silently never
|
|
84
|
+
* persist on a fresh install that also turned file logging off (RV-2).
|
|
85
|
+
* The `chmodSync` matches `store.ts` — rename does not preserve mode on every
|
|
86
|
+
* platform (RV-6).
|
|
87
|
+
*/
|
|
88
|
+
function writeToDisk(state) {
|
|
89
|
+
const path = getUiStatePath();
|
|
90
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(getHomeRoot(), { recursive: true });
|
|
93
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 });
|
|
94
|
+
renameSync(tmp, path);
|
|
95
|
+
if (process.platform !== 'win32')
|
|
96
|
+
chmodSync(path, 0o600);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
try {
|
|
100
|
+
rmSync(tmp, { force: true });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Nothing further to try; the value simply stays in memory (C2).
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function update(patch) {
|
|
108
|
+
const next = { ...loadUiState(), ...patch, schema: UI_STATE_SCHEMA };
|
|
109
|
+
memory = next;
|
|
110
|
+
writeToDisk(next);
|
|
111
|
+
return next;
|
|
112
|
+
}
|
|
113
|
+
export function readSubmitCount() {
|
|
114
|
+
return loadUiState().submitCount;
|
|
115
|
+
}
|
|
116
|
+
/** Increment, persist, and return the new value. */
|
|
117
|
+
export function bumpSubmitCount() {
|
|
118
|
+
return update({ submitCount: readSubmitCount() + 1 }).submitCount;
|
|
119
|
+
}
|
|
120
|
+
export function getMouseNoticeSeen() {
|
|
121
|
+
return loadUiState().mouseNoticeSeen;
|
|
122
|
+
}
|
|
123
|
+
export function setMouseNoticeSeen(seen) {
|
|
124
|
+
update({ mouseNoticeSeen: seen });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Migration-only: fill in fields that are still at their default, and never
|
|
128
|
+
* overwrite a value already here. A second import — after a half-finished
|
|
129
|
+
* migration retries — must not walk the counter backwards.
|
|
130
|
+
*/
|
|
131
|
+
export function importLegacyUiState(patch) {
|
|
132
|
+
const current = loadUiState();
|
|
133
|
+
const next = {};
|
|
134
|
+
if (patch.submitCount !== undefined &&
|
|
135
|
+
current.submitCount === DEFAULT_UI_STATE.submitCount) {
|
|
136
|
+
next.submitCount = coerceCount(patch.submitCount);
|
|
137
|
+
}
|
|
138
|
+
if (patch.mouseNoticeSeen !== undefined &&
|
|
139
|
+
current.mouseNoticeSeen === DEFAULT_UI_STATE.mouseNoticeSeen) {
|
|
140
|
+
next.mouseNoticeSeen = patch.mouseNoticeSeen === true;
|
|
141
|
+
}
|
|
142
|
+
if (Object.keys(next).length === 0)
|
|
143
|
+
return;
|
|
144
|
+
update(next);
|
|
145
|
+
}
|
|
146
|
+
/** Test-only: forget the cached state. */
|
|
147
|
+
export function resetUiStateForTests() {
|
|
148
|
+
memory = null;
|
|
149
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,153 @@
|
|
|
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 { PassThrough } from 'node:stream';
|
|
22
|
+
import { splitMouseEvents } from './mouse-events.js';
|
|
23
|
+
/**
|
|
24
|
+
* How long a possible-prefix tail is held before being flushed as text.
|
|
25
|
+
*
|
|
26
|
+
* This is what stops a lone `\x1b` — the Esc key, which aborts a run — from
|
|
27
|
+
* being held hostage waiting for a `[` that will never arrive. Worst-case Esc
|
|
28
|
+
* latency becomes 12 ms, and only for the keystroke that is literally a bare
|
|
29
|
+
* ESC; every other key resolves `isMousePrefix` to false immediately.
|
|
30
|
+
*/
|
|
31
|
+
const PENDING_FLUSH_MS = 12;
|
|
32
|
+
/** Assign a method onto the PassThrough without widening its public type. */
|
|
33
|
+
function define(target, name, value) {
|
|
34
|
+
Object.defineProperty(target, name, { value, writable: true, configurable: true });
|
|
35
|
+
}
|
|
36
|
+
export function createMouseFilter(real) {
|
|
37
|
+
const wrapper = new PassThrough();
|
|
38
|
+
const listeners = new Set();
|
|
39
|
+
let pending = '';
|
|
40
|
+
let flushTimer = null;
|
|
41
|
+
let disposed = false;
|
|
42
|
+
// --- The surface Ink reads off the stream it is handed. ------------------
|
|
43
|
+
// `isRawModeSupported()` reads `props.stdin.isTTY` (App.js:34), so this has
|
|
44
|
+
// to be a DELEGATING GETTER rather than a snapshot: a stdin that is not a TTY
|
|
45
|
+
// must still report so through the wrapper, or Ink throws instead of falling
|
|
46
|
+
// back.
|
|
47
|
+
Object.defineProperty(wrapper, 'isTTY', {
|
|
48
|
+
get: () => real.isTTY,
|
|
49
|
+
configurable: true,
|
|
50
|
+
});
|
|
51
|
+
define(wrapper, 'setRawMode', (mode) => {
|
|
52
|
+
real.setRawMode?.(mode);
|
|
53
|
+
return wrapper;
|
|
54
|
+
});
|
|
55
|
+
define(wrapper, 'ref', () => {
|
|
56
|
+
real.ref?.();
|
|
57
|
+
return wrapper;
|
|
58
|
+
});
|
|
59
|
+
define(wrapper, 'unref', () => {
|
|
60
|
+
real.unref?.();
|
|
61
|
+
return wrapper;
|
|
62
|
+
});
|
|
63
|
+
const emit = (event) => {
|
|
64
|
+
for (const listener of [...listeners])
|
|
65
|
+
listener(event);
|
|
66
|
+
};
|
|
67
|
+
const clearFlushTimer = () => {
|
|
68
|
+
if (!flushTimer)
|
|
69
|
+
return;
|
|
70
|
+
clearTimeout(flushTimer);
|
|
71
|
+
flushTimer = null;
|
|
72
|
+
};
|
|
73
|
+
const flushPending = () => {
|
|
74
|
+
clearFlushTimer();
|
|
75
|
+
if (pending.length === 0 || disposed)
|
|
76
|
+
return;
|
|
77
|
+
const text = pending;
|
|
78
|
+
pending = '';
|
|
79
|
+
wrapper.write(text);
|
|
80
|
+
};
|
|
81
|
+
const onData = (chunk) => {
|
|
82
|
+
if (disposed)
|
|
83
|
+
return;
|
|
84
|
+
clearFlushTimer();
|
|
85
|
+
const decoded = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
86
|
+
const split = splitMouseEvents(pending + decoded);
|
|
87
|
+
pending = split.pending;
|
|
88
|
+
if (split.text.length > 0)
|
|
89
|
+
wrapper.write(split.text);
|
|
90
|
+
for (const event of split.events)
|
|
91
|
+
emit(event);
|
|
92
|
+
if (pending.length > 0) {
|
|
93
|
+
// `.unref()` so a held tail can never keep the process alive on its own.
|
|
94
|
+
flushTimer = setTimeout(flushPending, PENDING_FLUSH_MS);
|
|
95
|
+
flushTimer.unref?.();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
// P2-7: a closed stdin must still terminate Ink's reader, or it waits forever
|
|
99
|
+
// on a stream that will never produce another byte.
|
|
100
|
+
const onEnd = () => {
|
|
101
|
+
flushPending();
|
|
102
|
+
if (!wrapper.writableEnded)
|
|
103
|
+
wrapper.end();
|
|
104
|
+
};
|
|
105
|
+
// `setEncoding('utf8')` on the REAL stream so Node's StringDecoder handles
|
|
106
|
+
// multi-byte boundaries for us — the parser works on characters, and a UTF-8
|
|
107
|
+
// sequence split across two chunks would otherwise become two mojibake
|
|
108
|
+
// characters before it ever reached `splitMouseEvents`.
|
|
109
|
+
real.setEncoding?.('utf8');
|
|
110
|
+
real.on('data', onData);
|
|
111
|
+
real.once('end', onEnd);
|
|
112
|
+
real.once('close', onEnd);
|
|
113
|
+
return {
|
|
114
|
+
stdin: wrapper,
|
|
115
|
+
source: {
|
|
116
|
+
subscribe(listener) {
|
|
117
|
+
listeners.add(listener);
|
|
118
|
+
return () => listeners.delete(listener);
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
dispose() {
|
|
122
|
+
if (disposed)
|
|
123
|
+
return;
|
|
124
|
+
disposed = true;
|
|
125
|
+
clearFlushTimer();
|
|
126
|
+
real.removeListener('data', onData);
|
|
127
|
+
real.removeListener('end', onEnd);
|
|
128
|
+
real.removeListener('close', onEnd);
|
|
129
|
+
listeners.clear();
|
|
130
|
+
// I-7: a live `'data'` listener on stdin keeps the event loop alive, and
|
|
131
|
+
// `aragon` would hang on exit instead of returning to the shell.
|
|
132
|
+
real.pause?.();
|
|
133
|
+
real.unref?.();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Build a filter, or `null` if construction throws.
|
|
139
|
+
*
|
|
140
|
+
* The `null` return is the whole point (P0-1): `enterAltScreen` derives whether
|
|
141
|
+
* to enable mouse reporting from `filter !== null`, so a construction failure
|
|
142
|
+
* degrades to "the wheel is inert" — a supported, tested rung of the fail-safe
|
|
143
|
+
* ladder — rather than leaving reporting on with no filter in front of Ink.
|
|
144
|
+
*/
|
|
145
|
+
export function tryCreateMouseFilter(real, onError) {
|
|
146
|
+
try {
|
|
147
|
+
return createMouseFilter(real);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
onError?.(err instanceof Error ? err.message : String(err));
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|