@aragon-agent/cli 0.6.3 → 0.6.4

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 (94) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +95 -2
  3. package/dist/agent/controller.d.ts +119 -0
  4. package/dist/agent/controller.js +275 -1
  5. package/dist/agent/reducer.d.ts +69 -0
  6. package/dist/agent/reducer.js +100 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/cli.js +84 -22
  10. package/dist/commands/builtins.js +92 -1
  11. package/dist/compaction/child.js +5 -0
  12. package/dist/compaction/command.js +14 -1
  13. package/dist/compaction/compactor.d.ts +34 -0
  14. package/dist/compaction/compactor.js +158 -37
  15. package/dist/compaction/types.d.ts +36 -0
  16. package/dist/compaction/wiring.d.ts +9 -0
  17. package/dist/compaction/wiring.js +22 -1
  18. package/dist/config/env.d.ts +1 -1
  19. package/dist/config/env.js +48 -1
  20. package/dist/config/load.d.ts +11 -0
  21. package/dist/config/load.js +39 -1
  22. package/dist/config/schema.d.ts +85 -0
  23. package/dist/config/schema.js +33 -0
  24. package/dist/config/store.js +21 -1
  25. package/dist/input/limits.d.ts +72 -0
  26. package/dist/input/limits.js +63 -0
  27. package/dist/input/paste-parse.d.ts +71 -0
  28. package/dist/input/paste-parse.js +95 -0
  29. package/dist/input/stdin-filter.d.ts +81 -0
  30. package/dist/input/stdin-filter.js +379 -0
  31. package/dist/logging/install.d.ts +2 -0
  32. package/dist/logging/install.js +53 -1
  33. package/dist/proc/classify.d.ts +52 -0
  34. package/dist/proc/classify.js +143 -0
  35. package/dist/proc/kill-tree.d.ts +42 -0
  36. package/dist/proc/kill-tree.js +119 -0
  37. package/dist/proc/limits.d.ts +84 -0
  38. package/dist/proc/limits.js +84 -0
  39. package/dist/proc/log-ring.d.ts +68 -0
  40. package/dist/proc/log-ring.js +102 -0
  41. package/dist/proc/prompt.d.ts +24 -0
  42. package/dist/proc/prompt.js +49 -0
  43. package/dist/proc/readiness.d.ts +102 -0
  44. package/dist/proc/readiness.js +219 -0
  45. package/dist/proc/supervisor.d.ts +103 -0
  46. package/dist/proc/supervisor.js +465 -0
  47. package/dist/proc/types.d.ts +175 -0
  48. package/dist/proc/types.js +15 -0
  49. package/dist/session/persist.js +36 -1
  50. package/dist/tools/bash-tool.d.ts +51 -2
  51. package/dist/tools/bash-tool.js +321 -51
  52. package/dist/tools/fs-tools.d.ts +17 -0
  53. package/dist/tools/index.d.ts +55 -2
  54. package/dist/tools/index.js +39 -1
  55. package/dist/tools/proc-tools.d.ts +25 -0
  56. package/dist/tools/proc-tools.js +102 -0
  57. package/dist/ui/App.d.ts +15 -2
  58. package/dist/ui/App.js +256 -7
  59. package/dist/ui/Composer.d.ts +54 -1
  60. package/dist/ui/Composer.js +50 -6
  61. package/dist/ui/PromptInput.d.ts +49 -3
  62. package/dist/ui/PromptInput.js +184 -52
  63. package/dist/ui/StatusBar.d.ts +19 -0
  64. package/dist/ui/StatusBar.js +5 -2
  65. package/dist/ui/Transcript.js +29 -1
  66. package/dist/ui/composer-limits.d.ts +31 -0
  67. package/dist/ui/composer-limits.js +50 -0
  68. package/dist/ui/composer-rows.d.ts +73 -0
  69. package/dist/ui/composer-rows.js +201 -0
  70. package/dist/ui/editor-reducer.d.ts +37 -0
  71. package/dist/ui/editor-reducer.js +88 -12
  72. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  73. package/dist/ui/entries/ServiceCard.js +116 -0
  74. package/dist/ui/entries/UserEntry.d.ts +17 -3
  75. package/dist/ui/entries/UserEntry.js +34 -5
  76. package/dist/ui/layout/budget.d.ts +34 -6
  77. package/dist/ui/layout/budget.js +32 -7
  78. package/dist/ui/layout/virtual-window.js +51 -1
  79. package/dist/ui/overlays/HelpOverlay.js +3 -0
  80. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  81. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  82. package/dist/ui/overlays/SettingsScreen.js +14 -1
  83. package/dist/ui/paste-frames.d.ts +87 -0
  84. package/dist/ui/paste-frames.js +112 -0
  85. package/dist/ui/paste-tokens.d.ts +90 -0
  86. package/dist/ui/paste-tokens.js +147 -0
  87. package/dist/ui/screen.d.ts +16 -0
  88. package/dist/ui/screen.js +45 -2
  89. package/dist/ui/selection/selection-controller.d.ts +1 -1
  90. package/dist/ui/transcript-text.js +49 -0
  91. package/dist/ui/use-wheel-routing.d.ts +1 -1
  92. package/package.json +2 -2
  93. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  94. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The paste token vocabulary (tui-paste-handling section 5.3) -- pure. No Ink,
3
+ * no React, no I/O.
4
+ *
5
+ * A large paste is replaced IN THE BUFFER by a display token,
6
+ * `[Pasted text #1 +15 lines]`, and its payload is held beside the buffer in the
7
+ * editor's `pastes` map until the message is sent (D-7). An inline token rather
8
+ * than an out-of-band attachment is what lets the user place a paste relative to
9
+ * their own words -- "explain this: [Pasted text #1 +218 lines] -- why does it
10
+ * retry?" -- which is also the shape the requirement's example asks for.
11
+ *
12
+ * The token is ATOMIC FOR EDITING and NOT for cursor movement (D-8). Delete and
13
+ * kill ranges snap outward to cover a whole token and the caret snaps out before
14
+ * an insert, which removes every realistic way to corrupt one; arrows still step
15
+ * by character, because re-deriving `moveVertical`, `home`/`end` and word jumps
16
+ * around a synthetic unit buys a correctness the user cannot notice.
17
+ */
18
+ import { PASTE_INLINE_MAX_CHARS, PASTE_INLINE_MAX_LINES } from './composer-limits.js';
19
+ /**
20
+ * A FACTORY, NOT A SHARED CONSTANT (P2-2).
21
+ *
22
+ * A module-level `/g` RegExp carries `lastIndex` between calls, so `re.test(x)`
23
+ * returns false on every second call with the same input -- a bug that presents
24
+ * as "token detection works, then randomly does not". Callers must use
25
+ * `matchAll` or `replace` (both safe) on a FRESH instance; nothing in this module
26
+ * may call `.test()` or `.exec()` on a `/g` regex it did not just create.
27
+ *
28
+ * Strict, anchored to its own literal shape, ASCII-only. Group 1 is the id.
29
+ */
30
+ export function pasteTokenRe() {
31
+ return /\[Pasted text #(\d+) \+\d+ (?:lines|chars)\]/g;
32
+ }
33
+ /**
34
+ * The process-monotonic id counter (D-14).
35
+ *
36
+ * CALLED FROM THE COMPONENT, NEVER FROM THE REDUCER (P1-5). React is explicitly
37
+ * allowed to run a reducer more than once for one action -- eager evaluation in
38
+ * `dispatchSetState`, base-queue replay after a bailout, StrictMode double
39
+ * invocation -- and a counter mutated inside one would skip ids and produce two
40
+ * states that differ in the token TEXT, not merely in identity. Allocating in the
41
+ * handler also makes the rendered token deterministic for a unit test.
42
+ *
43
+ * Monotonic for the PROCESS rather than per draft, so two drafts never show `#1`
44
+ * for different content in one session.
45
+ */
46
+ let nextPasteId = 1;
47
+ export function allocatePasteId() {
48
+ const id = nextPasteId;
49
+ nextPasteId += 1;
50
+ return id;
51
+ }
52
+ /** Test-only reset. Never called from application code. */
53
+ export function resetPasteIdsForTest() {
54
+ nextPasteId = 1;
55
+ }
56
+ export function makePasteRecord(id, text) {
57
+ return { id, text, lines: text.split('\n').length, chars: [...text].length };
58
+ }
59
+ /**
60
+ * `[Pasted text #3 +218 lines]`, or `[Pasted text #4 +1204 chars]` for a paste
61
+ * that is one very long line.
62
+ *
63
+ * "+1 lines" is not a sentence, and for a one-liner the number the user cares
64
+ * about is its length rather than its line count.
65
+ */
66
+ export function formatPasteToken(record) {
67
+ return record.lines > 1
68
+ ? `[Pasted text #${record.id} +${record.lines} lines]`
69
+ : `[Pasted text #${record.id} +${record.chars} chars]`;
70
+ }
71
+ /** Above EITHER inline bound the paste is collapsed into a token (G3 / G4). */
72
+ export function shouldCollapse(text) {
73
+ return text.split('\n').length > PASTE_INLINE_MAX_LINES || [...text].length > PASTE_INLINE_MAX_CHARS;
74
+ }
75
+ /** Every token in the buffer, in order. */
76
+ export function tokenSpans(buffer) {
77
+ const spans = [];
78
+ for (const m of buffer.matchAll(pasteTokenRe())) {
79
+ const start = m.index ?? 0;
80
+ spans.push({ start, end: start + m[0].length, id: Number(m[1]) });
81
+ }
82
+ return spans;
83
+ }
84
+ /** The token containing or touching `index`, or `null`. */
85
+ export function tokenAt(buffer, index) {
86
+ for (const span of tokenSpans(buffer)) {
87
+ if (index >= span.start && index <= span.end)
88
+ return span;
89
+ }
90
+ return null;
91
+ }
92
+ /**
93
+ * Move a caret that landed STRICTLY INSIDE a token to its nearer edge (D-8).
94
+ *
95
+ * An index at either edge, or outside every token, is returned unchanged -- so
96
+ * this is a no-op on a buffer with no tokens, which is every buffer today.
97
+ */
98
+ export function snapOutOfToken(buffer, cursor) {
99
+ for (const span of tokenSpans(buffer)) {
100
+ if (cursor <= span.start || cursor >= span.end)
101
+ continue;
102
+ return cursor - span.start <= span.end - cursor ? span.start : span.end;
103
+ }
104
+ return cursor;
105
+ }
106
+ /**
107
+ * Grow `[from, to)` so it covers every token it partially overlaps (G5).
108
+ *
109
+ * This is what makes `Backspace` immediately after `...lines]` remove the whole
110
+ * token in ONE keystroke, and what stops `Ctrl+W` / `Ctrl+U` / `Ctrl+K` leaving
111
+ * half a token -- and therefore an unparseable label -- behind.
112
+ */
113
+ export function expandRangeOverTokens(buffer, from, to) {
114
+ let lo = Math.min(from, to);
115
+ let hi = Math.max(from, to);
116
+ for (const span of tokenSpans(buffer)) {
117
+ if (span.start < hi && lo < span.end) {
118
+ lo = Math.min(lo, span.start);
119
+ hi = Math.max(hi, span.end);
120
+ }
121
+ }
122
+ return { from: lo, to: hi };
123
+ }
124
+ /**
125
+ * Replace every token whose id is present in `pastes`; leave the rest verbatim.
126
+ *
127
+ * A user who literally TYPES `[Pasted text #3 +9 lines]` while paste #3 exists in
128
+ * the same draft will have it expanded. That is bounded (ids are per process, the
129
+ * map is per draft) and harmless (they get text that is already in their own
130
+ * draft), and the alternative -- an unguessable nonce in the label -- would trade
131
+ * a case nobody hits for a label nobody can read.
132
+ */
133
+ export function expandPastes(buffer, pastes) {
134
+ if (pastes.size === 0)
135
+ return buffer;
136
+ return buffer.replace(pasteTokenRe(), (match, idText) => {
137
+ const record = pastes.get(Number(idText));
138
+ return record ? record.text : match;
139
+ });
140
+ }
141
+ /** Ids still referenced by the buffer -- the input to I-7's prune. */
142
+ export function referencedIds(buffer) {
143
+ const ids = new Set();
144
+ for (const m of buffer.matchAll(pasteTokenRe()))
145
+ ids.add(Number(m[1]));
146
+ return ids;
147
+ }
@@ -49,6 +49,22 @@ export interface AltScreenOptions {
49
49
  * pre-existing test emits exactly the bytes it emitted before (AC-8 / T-34).
50
50
  */
51
51
  readonly motion?: boolean;
52
+ /**
53
+ * TRUE MEANS "A FILTER THAT CONSUMES `\x1b[200~` IS ALREADY INSTALLED", NOT
54
+ * "the user wants paste support" — the same discipline `mouse` carries (I-1 /
55
+ * I-8).
56
+ *
57
+ * Enabling DEC 2004 without a filter in front of Ink does not degrade the
58
+ * feature: it types `[200~` and `[201~` into the user's message on EVERY paste,
59
+ * which is strictly worse than the bug being fixed. `cli.tsx` passes
60
+ * `pasteOn && mode === 'fullscreen'`, and NOT `filter !== null && config.paste`
61
+ * — after the gate widened, the handle no longer answers any question except
62
+ * "is a stream wrapped?" (D-17 / I-11).
63
+ *
64
+ * Optional, defaulting to `false`, so every pre-existing caller and every
65
+ * pre-existing assertion emits exactly the bytes it emitted before.
66
+ */
67
+ readonly bracketedPaste?: boolean;
52
68
  }
53
69
  /**
54
70
  * Enter the alternate screen. A non-TTY stdout is a hard no-op (tests, pipes,
package/dist/ui/screen.js CHANGED
@@ -55,6 +55,32 @@ const disableMouse = (motion) => `\x1b[?1006l${motion ? '\x1b[?1002l' : ''}\x1b[
55
55
  */
56
56
  const SAVE_ALT_SCROLL_OFF = '\x1b[?1007s\x1b[?1007l';
57
57
  const RESTORE_ALT_SCROLL = '\x1b[?1007r';
58
+ /**
59
+ * Bracketed paste (DEC private mode 2004) — the terminal wraps a paste in
60
+ * `\x1b[200~` / `\x1b[201~`, which makes Tier 1 detection EXACT rather than a
61
+ * heuristic on chunk shape (tui-paste-handling D-6 / L2).
62
+ *
63
+ * WHY THIS IS FORCED OFF ON EXIT RATHER THAN SAVED AND RESTORED (P1-8). DEC 1007
64
+ * above is the same class of state — global, not ours — and gets the OPPOSITE
65
+ * treatment, so the asymmetry has to be written down or the next reader will
66
+ * "fix" the inconsistency:
67
+ *
68
+ * - `\x1b[?2004r` on a terminal that does not implement XTSAVE is a NO-OP, which
69
+ * leaves 2004 ON. A shell that does not itself speak bracketed paste then
70
+ * shows a literal `[200~` on every paste until the user runs `reset` — the
71
+ * exact I-1 failure, arriving from the restore path.
72
+ * - Forcing `?2004l` has no such failure mode. Every shell that DOES use
73
+ * bracketed paste (bash >= 4.4 readline, zsh ZLE, fish, PSReadLine) re-arms it
74
+ * when it draws its next prompt, so the state the user sees after `aragon`
75
+ * exits is the state they had. Every shell that does not use it was already
76
+ * off.
77
+ *
78
+ * So: 1007 is saved because getting it wrong silently changes a preference; 2004
79
+ * is forced because getting it wrong visibly breaks pasting, and the forced value
80
+ * is the one every consumer re-establishes for itself.
81
+ */
82
+ const ENABLE_BRACKETED_PASTE = '\x1b[?2004h';
83
+ const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
58
84
  const NOOP_HANDLE = { restore() { }, setMouseCapture() { } };
59
85
  function safeWrite(stdout, data) {
60
86
  try {
@@ -73,7 +99,12 @@ export function enterAltScreen(stdout, options = { mouse: false }) {
73
99
  return NOOP_HANDLE;
74
100
  const mouse = options.mouse === true;
75
101
  const motion = options.motion === true;
76
- safeWrite(stdout, ENTER_ALT + CLEAR_VISIBLE + CURSOR_HOME + (mouse ? enableMouse(motion) : SAVE_ALT_SCROLL_OFF));
102
+ const bracketedPaste = options.bracketedPaste === true;
103
+ safeWrite(stdout, ENTER_ALT +
104
+ CLEAR_VISIBLE +
105
+ CURSOR_HOME +
106
+ (mouse ? enableMouse(motion) : SAVE_ALT_SCROLL_OFF) +
107
+ (bracketedPaste ? ENABLE_BRACKETED_PASTE : ''));
77
108
  /**
78
109
  * THE CURRENT capture state, not the one the session started in (I-10).
79
110
  *
@@ -93,6 +124,12 @@ export function enterAltScreen(stdout, options = { mouse: false }) {
93
124
  */
94
125
  let savedAltScroll = mouse ? false : true;
95
126
  const setMouseCapture = (on) => {
127
+ // IT MUST NOT TOUCH DEC 2004 (I-16 / P2-7). `/mouse off` releases the
128
+ // pointer; it says nothing about pasting, and a user who turns the mouse off
129
+ // mid-session to select text with their terminal must not silently lose Tier
130
+ // 1 detection as well. The two modes are written by different call sites and
131
+ // unwound by different flags in `restore()`.
132
+ //
96
133
  // A session that never installed a filter has nothing to release, and
97
134
  // enabling reporting without one types `[<0;12;5M` into the user's message
98
135
  // on every click (I-8). It stays a no-op in both directions.
@@ -134,7 +171,13 @@ export function enterAltScreen(stdout, options = { mouse: false }) {
134
171
  // read here rather than the startup `mouse` flag, so a session that ran
135
172
  // `/mouse off` exits with exactly one outstanding save restored and
136
173
  // nothing left reporting.
137
- const tail = (captured ? disableMouse(motion) : '') + (savedAltScroll ? RESTORE_ALT_SCROLL : '');
174
+ //
175
+ // DEC 2004 IS UNWOUND FROM THE STARTUP FLAG, not from a runtime one, and
176
+ // that is deliberate: `setMouseCapture` never touches it (I-16), so this
177
+ // session either enabled it at entry or never did.
178
+ const tail = (captured ? disableMouse(motion) : '') +
179
+ (bracketedPaste ? DISABLE_BRACKETED_PASTE : '') +
180
+ (savedAltScroll ? RESTORE_ALT_SCROLL : '');
138
181
  safeWrite(stdout, tail + SHOW_CURSOR + LEAVE_ALT);
139
182
  },
140
183
  setMouseCapture,
@@ -27,7 +27,7 @@
27
27
  * same `decorate` call. What you see is what you get by construction, rather
28
28
  * than by a staleness check that can be wrong.
29
29
  */
30
- import type { MouseSource } from '../../input/stdin-mouse-filter.js';
30
+ import type { MouseSource } from '../../input/stdin-filter.js';
31
31
  import type { Theme } from '../theme.js';
32
32
  import type { TermCapabilities } from '../capabilities.js';
33
33
  import type { CopyVia } from '../clipboard.js';
@@ -51,6 +51,34 @@ function safeParse(raw) {
51
51
  return null;
52
52
  }
53
53
  }
54
+ /**
55
+ * The replay's glyph for a service, MIRRORING `ServiceCard::serviceGlyph`.
56
+ *
57
+ * The two ladders have to agree, and the cheap-looking shortcut here — "ready or
58
+ * running is a tick, everything else is an error" — disagrees on the two states
59
+ * a user reaches ON PURPOSE: a service they stopped with `Ctrl+C`, and one that
60
+ * exited cleanly. Marking either as a failure in the record the user is left
61
+ * with after the alternate screen is torn down is a lie about their own action.
62
+ *
63
+ * Duplicated rather than imported, as every other case in this file duplicates
64
+ * its card's ladder: this module is deliberately free of React and ink, and
65
+ * `serviceGlyph` lives in a `.tsx`.
66
+ */
67
+ function serviceTextGlyph(status, exitCode, glyphs) {
68
+ switch (status) {
69
+ case 'ready':
70
+ return glyphs.toolDone;
71
+ case 'running':
72
+ return glyphs.toolRunning;
73
+ case 'starting':
74
+ case 'stopped':
75
+ return glyphs.toolPending;
76
+ case 'exited':
77
+ return exitCode === 0 ? glyphs.toolDone : glyphs.toolError;
78
+ default:
79
+ return glyphs.toolError;
80
+ }
81
+ }
54
82
  function renderEntry(entry, glyphs) {
55
83
  switch (entry.kind) {
56
84
  case 'user':
@@ -134,6 +162,27 @@ function renderEntry(entry, glyphs) {
134
162
  ...entry.summary.split('\n').map((l) => `${TOOL_INDENT}${CONTINUATION_INDENT}${l}`),
135
163
  ];
136
164
  }
165
+ case 'service': {
166
+ // A service the agent started is real work with a real outcome, and an
167
+ // exported transcript that omitted it would be unaccountable for both —
168
+ // the argument the `team`, `retry`, `fast` and `compaction` cases above
169
+ // each make in their own words. It matters more here than for any of them:
170
+ // this replay is the user's ONLY record once the alternate screen is torn
171
+ // down (`cli.tsx`'s exit snapshot), and a service card dropped from it
172
+ // takes the URL the agent reported with it. This file's
173
+ // `default: return []` means the addition is a CHOICE rather than a
174
+ // compile requirement, which is exactly why it is easy to leave out
175
+ // (P0-3).
176
+ const glyph = serviceTextGlyph(entry.status, entry.exitCode, glyphs);
177
+ const where = entry.url ? ` ${entry.url}` : '';
178
+ const code = entry.exitCode === null || entry.exitCode === undefined
179
+ ? ''
180
+ : ` (exit code ${entry.exitCode})`;
181
+ return [
182
+ `${TOOL_INDENT}${glyph} service ${entry.serviceId} ${entry.command} ` +
183
+ `${entry.status}${where}${code}`.trimEnd(),
184
+ ];
185
+ }
137
186
  case 'notice': {
138
187
  const glyph = entry.level === 'error'
139
188
  ? glyphs.error
@@ -20,7 +20,7 @@
20
20
  * the wheel spins: one gesture, one `setState`, one render.
21
21
  */
22
22
  import type { Overlay } from '../agent/reducer.js';
23
- import type { MouseSource } from '../input/stdin-mouse-filter.js';
23
+ import type { MouseSource } from '../input/stdin-filter.js';
24
24
  import type { ScrollIntent } from './layout/scroll.js';
25
25
  /**
26
26
  * Rows a PgUp/PgDn — and a Shift+wheel notch — moves inside a controlled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@aragon-agent/core": "^0.2.15",
59
+ "@aragon-agent/core": "^0.2.16",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",
@@ -1,47 +0,0 @@
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 MouseEvent } from './mouse-events.js';
22
- export interface MouseSource {
23
- /**
24
- * Returns an unsubscribe function. Multiple subscribers are allowed — the
25
- * wheel router and (since tui-selection-and-scroll-follow) the selection
26
- * controller both take this one channel, and each ignores the kinds it does
27
- * not own.
28
- */
29
- subscribe(listener: (event: MouseEvent) => void): () => void;
30
- }
31
- export interface MouseFilter {
32
- /** Hand this to Ink: `render(<App/>, { stdin: filter.stdin })`. */
33
- readonly stdin: NodeJS.ReadStream;
34
- readonly source: MouseSource;
35
- /** Idempotent. Detaches from the real stdin and clears the flush timer (I-7). */
36
- dispose(): void;
37
- }
38
- export declare function createMouseFilter(real: NodeJS.ReadStream): MouseFilter;
39
- /**
40
- * Build a filter, or `null` if construction throws.
41
- *
42
- * The `null` return is the whole point (P0-1): `enterAltScreen` derives whether
43
- * to enable mouse reporting from `filter !== null`, so a construction failure
44
- * degrades to "the wheel is inert" — a supported, tested rung of the fail-safe
45
- * ladder — rather than leaving reporting on with no filter in front of Ink.
46
- */
47
- export declare function tryCreateMouseFilter(real: NodeJS.ReadStream, onError?: (reason: string) => void): MouseFilter | null;
@@ -1,153 +0,0 @@
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
- }