@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +155 -5
  3. package/dist/agent/controller.d.ts +51 -1
  4. package/dist/agent/controller.js +177 -2
  5. package/dist/agent/headless.d.ts +12 -0
  6. package/dist/agent/headless.js +41 -1
  7. package/dist/agent/reducer.d.ts +48 -0
  8. package/dist/agent/reducer.js +91 -2
  9. package/dist/agent/system-prompt.d.ts +17 -0
  10. package/dist/agent/system-prompt.js +4 -0
  11. package/dist/cli.js +131 -6
  12. package/dist/commands/builtins.js +82 -0
  13. package/dist/config/app-paths.d.ts +17 -0
  14. package/dist/config/app-paths.js +21 -0
  15. package/dist/config/cli-commands.d.ts +26 -2
  16. package/dist/config/cli-commands.js +63 -4
  17. package/dist/config/env.d.ts +2 -2
  18. package/dist/config/env.js +36 -2
  19. package/dist/config/history-commands.d.ts +20 -0
  20. package/dist/config/history-commands.js +71 -0
  21. package/dist/config/load.d.ts +15 -0
  22. package/dist/config/load.js +88 -5
  23. package/dist/config/migrate-state-out-of-config.d.ts +32 -0
  24. package/dist/config/migrate-state-out-of-config.js +85 -0
  25. package/dist/config/prompt-history.d.ts +86 -0
  26. package/dist/config/prompt-history.js +332 -0
  27. package/dist/config/schema.d.ts +125 -9
  28. package/dist/config/schema.js +90 -5
  29. package/dist/config/store.js +13 -4
  30. package/dist/config/ui-state.d.ts +52 -0
  31. package/dist/config/ui-state.js +149 -0
  32. package/dist/input/mouse-events.d.ts +61 -0
  33. package/dist/input/mouse-events.js +153 -0
  34. package/dist/input/stdin-mouse-filter.d.ts +42 -0
  35. package/dist/input/stdin-mouse-filter.js +153 -0
  36. package/dist/logging/install.d.ts +85 -0
  37. package/dist/logging/install.js +94 -0
  38. package/dist/logging/logger.d.ts +1 -1
  39. package/dist/session/persist.d.ts +16 -0
  40. package/dist/session/persist.js +19 -1
  41. package/dist/team/activity.d.ts +67 -0
  42. package/dist/team/activity.js +130 -0
  43. package/dist/team/bus.d.ts +190 -0
  44. package/dist/team/bus.js +366 -0
  45. package/dist/team/comm-tools.d.ts +61 -0
  46. package/dist/team/comm-tools.js +221 -0
  47. package/dist/team/human-queue.d.ts +55 -0
  48. package/dist/team/human-queue.js +72 -0
  49. package/dist/team/limits.d.ts +123 -0
  50. package/dist/team/limits.js +128 -0
  51. package/dist/team/normalize.d.ts +39 -0
  52. package/dist/team/normalize.js +106 -0
  53. package/dist/team/panel-rows.d.ts +48 -0
  54. package/dist/team/panel-rows.js +75 -0
  55. package/dist/team/prompt.d.ts +49 -0
  56. package/dist/team/prompt.js +87 -0
  57. package/dist/team/report.d.ts +58 -0
  58. package/dist/team/report.js +205 -0
  59. package/dist/team/retry.d.ts +48 -0
  60. package/dist/team/retry.js +56 -0
  61. package/dist/team/runtime.d.ts +122 -0
  62. package/dist/team/runtime.js +406 -0
  63. package/dist/team/subagent.d.ts +90 -0
  64. package/dist/team/subagent.js +294 -0
  65. package/dist/team/task-tool.d.ts +47 -0
  66. package/dist/team/task-tool.js +118 -0
  67. package/dist/team/types.d.ts +172 -0
  68. package/dist/team/types.js +13 -0
  69. package/dist/tools/index.d.ts +48 -2
  70. package/dist/tools/index.js +26 -1
  71. package/dist/ui/App.d.ts +9 -1
  72. package/dist/ui/App.js +140 -21
  73. package/dist/ui/Composer.d.ts +6 -1
  74. package/dist/ui/Composer.js +2 -2
  75. package/dist/ui/PromptInput.d.ts +15 -1
  76. package/dist/ui/PromptInput.js +38 -1
  77. package/dist/ui/StatusBar.d.ts +21 -0
  78. package/dist/ui/StatusBar.js +5 -2
  79. package/dist/ui/TeamPanel.d.ts +57 -0
  80. package/dist/ui/TeamPanel.js +134 -0
  81. package/dist/ui/Transcript.js +10 -0
  82. package/dist/ui/entries/TeamCard.d.ts +31 -0
  83. package/dist/ui/entries/TeamCard.js +39 -0
  84. package/dist/ui/glyphs.d.ts +22 -0
  85. package/dist/ui/glyphs.js +10 -0
  86. package/dist/ui/layout/AppShell.d.ts +20 -1
  87. package/dist/ui/layout/AppShell.js +39 -4
  88. package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
  89. package/dist/ui/layout/ScrollIndicator.js +15 -0
  90. package/dist/ui/layout/ScrollViewport.d.ts +8 -2
  91. package/dist/ui/layout/ScrollViewport.js +24 -4
  92. package/dist/ui/layout/regions.d.ts +55 -0
  93. package/dist/ui/layout/regions.js +52 -0
  94. package/dist/ui/layout/scroll-indicator.d.ts +27 -0
  95. package/dist/ui/layout/scroll-indicator.js +35 -0
  96. package/dist/ui/layout/scroll.d.ts +9 -0
  97. package/dist/ui/layout/scroll.js +23 -0
  98. package/dist/ui/overlays/HelpOverlay.js +14 -0
  99. package/dist/ui/screen.d.ts +14 -1
  100. package/dist/ui/screen.js +37 -3
  101. package/dist/ui/transcript-text.js +20 -0
  102. package/dist/ui/use-startup-notices.d.ts +11 -1
  103. package/dist/ui/use-startup-notices.js +29 -2
  104. package/dist/ui/use-wheel-routing.d.ts +68 -0
  105. package/dist/ui/use-wheel-routing.js +139 -0
  106. package/package.json +2 -2
@@ -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
- const partial = readConfigFile().config ?? {};
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
+ }