@aragon-agent/cli 0.6.3 → 0.6.7

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 (138) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +203 -13
  3. package/dist/agent/controller.d.ts +158 -2
  4. package/dist/agent/controller.js +400 -1
  5. package/dist/agent/reducer.d.ts +102 -19
  6. package/dist/agent/reducer.js +162 -71
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/agent/usage.d.ts +34 -0
  10. package/dist/agent/usage.js +43 -0
  11. package/dist/cli.js +132 -23
  12. package/dist/commands/builtins.js +101 -1
  13. package/dist/compaction/child.js +5 -0
  14. package/dist/compaction/command.js +14 -1
  15. package/dist/compaction/compactor.d.ts +94 -24
  16. package/dist/compaction/compactor.js +247 -98
  17. package/dist/compaction/context-command.d.ts +33 -0
  18. package/dist/compaction/context-command.js +166 -0
  19. package/dist/compaction/meter.d.ts +251 -0
  20. package/dist/compaction/meter.js +428 -0
  21. package/dist/compaction/pressure.d.ts +6 -0
  22. package/dist/compaction/pressure.js +4 -0
  23. package/dist/compaction/types.d.ts +77 -0
  24. package/dist/compaction/wiring.d.ts +56 -0
  25. package/dist/compaction/wiring.js +107 -25
  26. package/dist/config/cli-commands.js +5 -0
  27. package/dist/config/env.d.ts +1 -1
  28. package/dist/config/env.js +63 -2
  29. package/dist/config/load.d.ts +28 -0
  30. package/dist/config/load.js +83 -1
  31. package/dist/config/schema.d.ts +129 -0
  32. package/dist/config/schema.js +69 -0
  33. package/dist/config/store.js +47 -1
  34. package/dist/exec/events.d.ts +138 -1
  35. package/dist/exec/events.js +42 -0
  36. package/dist/exec/index.js +43 -6
  37. package/dist/exec/progress-reporter.d.ts +23 -0
  38. package/dist/exec/progress-reporter.js +88 -0
  39. package/dist/exec/runner.d.ts +34 -0
  40. package/dist/exec/runner.js +137 -25
  41. package/dist/fast/reviewer.d.ts +19 -1
  42. package/dist/fast/reviewer.js +22 -0
  43. package/dist/fast/types.d.ts +15 -0
  44. package/dist/fast/wiring.d.ts +10 -1
  45. package/dist/fast/wiring.js +18 -1
  46. package/dist/input/limits.d.ts +72 -0
  47. package/dist/input/limits.js +63 -0
  48. package/dist/input/paste-parse.d.ts +71 -0
  49. package/dist/input/paste-parse.js +95 -0
  50. package/dist/input/stdin-filter.d.ts +81 -0
  51. package/dist/input/stdin-filter.js +379 -0
  52. package/dist/logging/install.d.ts +2 -0
  53. package/dist/logging/install.js +53 -1
  54. package/dist/proc/classify.d.ts +52 -0
  55. package/dist/proc/classify.js +143 -0
  56. package/dist/proc/kill-tree.d.ts +42 -0
  57. package/dist/proc/kill-tree.js +119 -0
  58. package/dist/proc/limits.d.ts +84 -0
  59. package/dist/proc/limits.js +84 -0
  60. package/dist/proc/log-ring.d.ts +68 -0
  61. package/dist/proc/log-ring.js +102 -0
  62. package/dist/proc/prompt.d.ts +24 -0
  63. package/dist/proc/prompt.js +49 -0
  64. package/dist/proc/readiness.d.ts +102 -0
  65. package/dist/proc/readiness.js +219 -0
  66. package/dist/proc/supervisor.d.ts +103 -0
  67. package/dist/proc/supervisor.js +465 -0
  68. package/dist/proc/types.d.ts +175 -0
  69. package/dist/proc/types.js +15 -0
  70. package/dist/session/persist.js +36 -1
  71. package/dist/todo/limits.d.ts +4 -17
  72. package/dist/todo/limits.js +4 -17
  73. package/dist/todo/panel-rows.d.ts +8 -6
  74. package/dist/todo/panel-rows.js +19 -6
  75. package/dist/tools/bash-tool.d.ts +51 -2
  76. package/dist/tools/bash-tool.js +321 -51
  77. package/dist/tools/fs-tools.d.ts +17 -0
  78. package/dist/tools/index.d.ts +55 -2
  79. package/dist/tools/index.js +39 -1
  80. package/dist/tools/proc-tools.d.ts +25 -0
  81. package/dist/tools/proc-tools.js +102 -0
  82. package/dist/ui/App.d.ts +15 -2
  83. package/dist/ui/App.js +330 -69
  84. package/dist/ui/AutocompletePopup.d.ts +3 -1
  85. package/dist/ui/AutocompletePopup.js +9 -11
  86. package/dist/ui/BottomStatusRow.d.ts +1 -1
  87. package/dist/ui/Composer.d.ts +56 -1
  88. package/dist/ui/Composer.js +50 -6
  89. package/dist/ui/PromptInput.d.ts +52 -3
  90. package/dist/ui/PromptInput.js +201 -55
  91. package/dist/ui/StatusBar.d.ts +40 -15
  92. package/dist/ui/StatusBar.js +51 -9
  93. package/dist/ui/TeamPanel.d.ts +3 -1
  94. package/dist/ui/TeamPanel.js +13 -9
  95. package/dist/ui/TodoPanel.d.ts +5 -5
  96. package/dist/ui/TodoPanel.js +56 -24
  97. package/dist/ui/Transcript.js +29 -1
  98. package/dist/ui/UpdateLine.d.ts +1 -1
  99. package/dist/ui/capabilities.js +5 -5
  100. package/dist/ui/composer-limits.d.ts +31 -0
  101. package/dist/ui/composer-limits.js +50 -0
  102. package/dist/ui/composer-rows.d.ts +73 -0
  103. package/dist/ui/composer-rows.js +201 -0
  104. package/dist/ui/editor-reducer.d.ts +37 -0
  105. package/dist/ui/editor-reducer.js +88 -12
  106. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  107. package/dist/ui/entries/ServiceCard.js +116 -0
  108. package/dist/ui/entries/UserEntry.d.ts +17 -3
  109. package/dist/ui/entries/UserEntry.js +34 -5
  110. package/dist/ui/exit-snapshot.d.ts +9 -0
  111. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  112. package/dist/ui/layout/autocomplete.d.ts +18 -0
  113. package/dist/ui/layout/autocomplete.js +26 -0
  114. package/dist/ui/layout/budget.d.ts +34 -6
  115. package/dist/ui/layout/budget.js +32 -7
  116. package/dist/ui/layout/rail.d.ts +4 -53
  117. package/dist/ui/layout/rail.js +10 -62
  118. package/dist/ui/layout/team-panel.d.ts +18 -0
  119. package/dist/ui/layout/team-panel.js +22 -0
  120. package/dist/ui/layout/todo-layout.d.ts +24 -0
  121. package/dist/ui/layout/todo-layout.js +27 -0
  122. package/dist/ui/layout/virtual-window.js +51 -1
  123. package/dist/ui/overlays/HelpOverlay.js +3 -0
  124. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  125. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  126. package/dist/ui/overlays/SettingsScreen.js +14 -1
  127. package/dist/ui/paste-frames.d.ts +87 -0
  128. package/dist/ui/paste-frames.js +112 -0
  129. package/dist/ui/paste-tokens.d.ts +90 -0
  130. package/dist/ui/paste-tokens.js +147 -0
  131. package/dist/ui/screen.d.ts +16 -0
  132. package/dist/ui/screen.js +45 -2
  133. package/dist/ui/selection/selection-controller.d.ts +1 -1
  134. package/dist/ui/transcript-text.js +55 -2
  135. package/dist/ui/use-wheel-routing.d.ts +1 -1
  136. package/package.json +2 -2
  137. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  138. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -19,6 +19,7 @@ import { useState } from 'react';
19
19
  import { Box, Text, useInput } from 'ink';
20
20
  import { pickGlyphs } from '../glyphs.js';
21
21
  import { OverlayFrame } from '../layout/OverlayFrame.js';
22
+ import { stripPasteFrames } from '../paste-frames.js';
22
23
  /** Which option index each question starts on: the recommended one. */
23
24
  function initialCursors(questions) {
24
25
  return questions.map((q) => Math.max(0, q.options.findIndex((o) => o.recommended)));
@@ -121,8 +122,11 @@ export function QuestionOverlay({ questions, maxRows, cols, theme, caps, onSubmi
121
122
  setDraft(draft.slice(0, -1));
122
123
  return;
123
124
  }
125
+ // `stripPasteFrames`, NOT `input` (I-12 / P0-2) -- the same broadcast
126
+ // hazard `SettingsScreen` documents at length: a framed paste appended
127
+ // verbatim stores NULs that render as nothing.
124
128
  if (input && !key.ctrl && !key.meta && !key.tab)
125
- setDraft(draft + input);
129
+ setDraft(draft + stripPasteFrames(input));
126
130
  return;
127
131
  }
128
132
  // phase === 'choosing'
@@ -15,6 +15,7 @@ import { ADAPTER_PROVIDERS, THINKING_LEVELS, maskSecret, parseMaxTokensInput, pa
15
15
  import { LOG_LEVEL_NAMES } from '../../logging/levels.js';
16
16
  import { pickGlyphs } from '../glyphs.js';
17
17
  import { OverlayFrame } from '../layout/OverlayFrame.js';
18
+ import { stripPasteFrames } from '../paste-frames.js';
18
19
  const SHOW_THINKING_OPTIONS = ['off', 'on'];
19
20
  const LIVE_OUTPUT_OPTIONS = ['off', 'on'];
20
21
  const FAST_ENABLED_OPTIONS = ['off', 'on'];
@@ -292,7 +293,19 @@ export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset,
292
293
  return;
293
294
  }
294
295
  if (input && !key.ctrl && !key.meta) {
295
- setValues((prev) => ({ ...prev, [field.key]: String(prev[field.key]) + input }));
296
+ // `stripPasteFrames`, NOT `input` (I-12 / P0-2). `useInput` is a BROADCAST,
297
+ // so this handler sees the NUL-framed paste the composer was sent. An API
298
+ // key is ~100 characters with no line break, which trips the filter's Tier
299
+ // 2 burst rule and gets framed -- and nothing downstream would remove the
300
+ // NULs: `trim()` does not treat U+0000 as whitespace, `maskDot` hides the
301
+ // damage, and the value is handed to `registerSecret()` and
302
+ // `controller.setApiKey()`. The user is told the settings were saved and
303
+ // every request afterwards fails to authenticate with nothing on screen to
304
+ // explain it. Pasting IS how a key enters this process.
305
+ const text = stripPasteFrames(input);
306
+ if (text.length === 0)
307
+ return;
308
+ setValues((prev) => ({ ...prev, [field.key]: String(prev[field.key]) + text }));
296
309
  }
297
310
  });
298
311
  const displayValue = (f, selected) => {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Reading paste frames back out of an Ink `input` string
3
+ * (tui-paste-handling section 5.4) -- pure.
4
+ *
5
+ * The stdin filter writes `PASTE_OPEN + payload + PASTE_CLOSE` inline, in stream
6
+ * order (I-5). This module is the only place that takes those markers apart.
7
+ */
8
+ export interface FrameTextSegment {
9
+ readonly kind: 'text';
10
+ readonly text: string;
11
+ }
12
+ export interface FramePasteSegment {
13
+ readonly kind: 'paste';
14
+ readonly text: string;
15
+ }
16
+ /**
17
+ * A run of one `input` string.
18
+ *
19
+ * NO `id` HERE. The paste id is allocated by `PromptInput` while it builds the
20
+ * reducer's action, never by this function and never by the reducer (P1-5), so
21
+ * the split stays a pure function of its argument and `paste-frames.test.ts` can
22
+ * assert on its output.
23
+ */
24
+ export type FrameSegment = FrameTextSegment | FramePasteSegment;
25
+ /**
26
+ * Remove every C0 byte, DEL and C1 byte from text that arrived OUTSIDE a frame
27
+ * (I-14). NO CR -> LF conversion, and that omission is the point.
28
+ *
29
+ * WHY THIS IS NOT OPTIONAL. Ink drains its whole buffer in one size-less
30
+ * `read()` (`App.js:135`), so the `input` string carrying a frame can also carry
31
+ * the keystroke bytes that arrived beside it -- most often the `\r` of a user who
32
+ * pastes and immediately presses Enter, since Ink's 32 ms render throttle and a
33
+ * large transcript's Yoga pass both outlast the filter's 15 ms burst window.
34
+ * Splicing that run in verbatim puts a raw CR back into the draft and re-opens
35
+ * the corruption this whole feature exists to remove.
36
+ *
37
+ * Note what it does NOT do. It does not turn a coalesced `\r` into a newline and
38
+ * it does not submit: a keystroke that merged with a frame has already lost its
39
+ * identity as a key (`parseKeypress` saw the concatenation, not the key), so the
40
+ * only two honest options are "insert something wrong" and "drop it". Dropping is
41
+ * recoverable and benign -- the user presses Enter again -- while inserting a
42
+ * control byte is not. Printable characters survive untouched.
43
+ */
44
+ export declare function sanitiseTyped(text: string): string;
45
+ /**
46
+ * Split an `input` string into ordered text runs and paste runs.
47
+ *
48
+ * Text runs are returned already through `sanitiseTyped` (I-14); paste runs are
49
+ * returned through `sanitisePaste`, which keeps TAB and `\n` because they are
50
+ * content inside a payload.
51
+ *
52
+ * RE-SANITISING THE PAYLOAD IS DEFENCE IN DEPTH, NOT A SECOND OPINION. The
53
+ * filter already ran `sanitisePaste` and it is idempotent, so the normal path is
54
+ * byte-identical. What it buys is that G2 ("no pasted byte is ever executed as a
55
+ * terminal control") becomes a property of the COMPOSER rather than a property
56
+ * of whoever produced the frame -- which matters for the branch below, where an
57
+ * unmatched `PASTE_OPEN` turns arbitrary trailing bytes into a paste segment.
58
+ *
59
+ * An unmatched `PASTE_OPEN` -- impossible from our own filter, but cheap to
60
+ * survive -- yields its remainder as a paste segment rather than leaking NULs.
61
+ */
62
+ export declare function splitPasteFrames(input: string): FrameSegment[];
63
+ /** True when this `input` carries at least one frame -- the branch guard. */
64
+ export declare function hasPasteFrame(input: string): boolean;
65
+ /**
66
+ * Turn a possibly-framed `input` into plain text for a consumer that has no
67
+ * paste model of its own: unwrap the frames, keep the payload, drop the markers,
68
+ * and run BOTH halves through `sanitiseTyped`.
69
+ *
70
+ * ANY `useInput` CONSUMER THAT APPENDS `input` TO A STRING MUST CALL THIS (I-12),
71
+ * and the list is pinned by a source scan rather than by review discipline
72
+ * because the failure is invisible: NUL renders as nothing.
73
+ *
74
+ * `useInput` is a BROADCAST -- `use-input.js` re-emits every chunk to every
75
+ * mounted handler -- so `SettingsScreen` and `QuestionOverlay` see the frame the
76
+ * composer was sent. An API key is ~100 characters with no line break, which trips
77
+ * Tier 2's burst rule and gets framed; nothing downstream removes the NULs
78
+ * (`String.prototype.trim()` does not treat U+0000 as whitespace), the field is
79
+ * rendered through `maskDot` so the damage is invisible, and the value is handed
80
+ * to `registerSecret()` and `controller.setApiKey()`. The user is told the
81
+ * settings were saved and every request afterwards fails to authenticate with
82
+ * nothing on screen to explain it.
83
+ *
84
+ * This is strictly better than v0.6.3 for both consumers: a pasted key carrying a
85
+ * stray CR or an ANSI sequence is now cleaned rather than stored raw.
86
+ */
87
+ export declare function stripPasteFrames(input: string): string;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Reading paste frames back out of an Ink `input` string
3
+ * (tui-paste-handling section 5.4) -- pure.
4
+ *
5
+ * The stdin filter writes `PASTE_OPEN + payload + PASTE_CLOSE` inline, in stream
6
+ * order (I-5). This module is the only place that takes those markers apart.
7
+ */
8
+ import { PASTE_CLOSE, PASTE_OPEN } from '../input/limits.js';
9
+ import { sanitisePaste } from '../input/paste-parse.js';
10
+ /**
11
+ * Remove every C0 byte, DEL and C1 byte from text that arrived OUTSIDE a frame
12
+ * (I-14). NO CR -> LF conversion, and that omission is the point.
13
+ *
14
+ * WHY THIS IS NOT OPTIONAL. Ink drains its whole buffer in one size-less
15
+ * `read()` (`App.js:135`), so the `input` string carrying a frame can also carry
16
+ * the keystroke bytes that arrived beside it -- most often the `\r` of a user who
17
+ * pastes and immediately presses Enter, since Ink's 32 ms render throttle and a
18
+ * large transcript's Yoga pass both outlast the filter's 15 ms burst window.
19
+ * Splicing that run in verbatim puts a raw CR back into the draft and re-opens
20
+ * the corruption this whole feature exists to remove.
21
+ *
22
+ * Note what it does NOT do. It does not turn a coalesced `\r` into a newline and
23
+ * it does not submit: a keystroke that merged with a frame has already lost its
24
+ * identity as a key (`parseKeypress` saw the concatenation, not the key), so the
25
+ * only two honest options are "insert something wrong" and "drop it". Dropping is
26
+ * recoverable and benign -- the user presses Enter again -- while inserting a
27
+ * control byte is not. Printable characters survive untouched.
28
+ */
29
+ export function sanitiseTyped(text) {
30
+ return text.replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
31
+ }
32
+ /**
33
+ * Split an `input` string into ordered text runs and paste runs.
34
+ *
35
+ * Text runs are returned already through `sanitiseTyped` (I-14); paste runs are
36
+ * returned through `sanitisePaste`, which keeps TAB and `\n` because they are
37
+ * content inside a payload.
38
+ *
39
+ * RE-SANITISING THE PAYLOAD IS DEFENCE IN DEPTH, NOT A SECOND OPINION. The
40
+ * filter already ran `sanitisePaste` and it is idempotent, so the normal path is
41
+ * byte-identical. What it buys is that G2 ("no pasted byte is ever executed as a
42
+ * terminal control") becomes a property of the COMPOSER rather than a property
43
+ * of whoever produced the frame -- which matters for the branch below, where an
44
+ * unmatched `PASTE_OPEN` turns arbitrary trailing bytes into a paste segment.
45
+ *
46
+ * An unmatched `PASTE_OPEN` -- impossible from our own filter, but cheap to
47
+ * survive -- yields its remainder as a paste segment rather than leaking NULs.
48
+ */
49
+ export function splitPasteFrames(input) {
50
+ const segments = [];
51
+ const pushText = (raw) => {
52
+ const text = sanitiseTyped(raw);
53
+ if (text.length > 0)
54
+ segments.push({ kind: 'text', text });
55
+ };
56
+ const pushPaste = (raw) => {
57
+ const text = sanitisePaste(raw);
58
+ if (text.length > 0)
59
+ segments.push({ kind: 'paste', text });
60
+ };
61
+ let i = 0;
62
+ while (i < input.length) {
63
+ const open = input.indexOf(PASTE_OPEN, i);
64
+ if (open === -1) {
65
+ pushText(input.slice(i));
66
+ break;
67
+ }
68
+ pushText(input.slice(i, open));
69
+ const bodyStart = open + PASTE_OPEN.length;
70
+ const close = input.indexOf(PASTE_CLOSE, bodyStart);
71
+ if (close === -1) {
72
+ pushPaste(input.slice(bodyStart));
73
+ break;
74
+ }
75
+ pushPaste(input.slice(bodyStart, close));
76
+ i = close + PASTE_CLOSE.length;
77
+ }
78
+ return segments;
79
+ }
80
+ /** True when this `input` carries at least one frame -- the branch guard. */
81
+ export function hasPasteFrame(input) {
82
+ return input.includes(PASTE_OPEN);
83
+ }
84
+ /**
85
+ * Turn a possibly-framed `input` into plain text for a consumer that has no
86
+ * paste model of its own: unwrap the frames, keep the payload, drop the markers,
87
+ * and run BOTH halves through `sanitiseTyped`.
88
+ *
89
+ * ANY `useInput` CONSUMER THAT APPENDS `input` TO A STRING MUST CALL THIS (I-12),
90
+ * and the list is pinned by a source scan rather than by review discipline
91
+ * because the failure is invisible: NUL renders as nothing.
92
+ *
93
+ * `useInput` is a BROADCAST -- `use-input.js` re-emits every chunk to every
94
+ * mounted handler -- so `SettingsScreen` and `QuestionOverlay` see the frame the
95
+ * composer was sent. An API key is ~100 characters with no line break, which trips
96
+ * Tier 2's burst rule and gets framed; nothing downstream removes the NULs
97
+ * (`String.prototype.trim()` does not treat U+0000 as whitespace), the field is
98
+ * rendered through `maskDot` so the damage is invisible, and the value is handed
99
+ * to `registerSecret()` and `controller.setApiKey()`. The user is told the
100
+ * settings were saved and every request afterwards fails to authenticate with
101
+ * nothing on screen to explain it.
102
+ *
103
+ * This is strictly better than v0.6.3 for both consumers: a pasted key carrying a
104
+ * stray CR or an ANSI sequence is now cleaned rather than stored raw.
105
+ */
106
+ export function stripPasteFrames(input) {
107
+ if (!hasPasteFrame(input))
108
+ return sanitiseTyped(input);
109
+ return splitPasteFrames(input)
110
+ .map((segment) => (segment.kind === 'paste' ? sanitiseTyped(segment.text) : segment.text))
111
+ .join('');
112
+ }
@@ -0,0 +1,90 @@
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
+ export interface PasteRecord {
19
+ readonly id: number;
20
+ /** The sanitised payload, byte-for-byte what `expandPastes` sends. */
21
+ readonly text: string;
22
+ readonly lines: number;
23
+ readonly chars: number;
24
+ }
25
+ /**
26
+ * A FACTORY, NOT A SHARED CONSTANT (P2-2).
27
+ *
28
+ * A module-level `/g` RegExp carries `lastIndex` between calls, so `re.test(x)`
29
+ * returns false on every second call with the same input -- a bug that presents
30
+ * as "token detection works, then randomly does not". Callers must use
31
+ * `matchAll` or `replace` (both safe) on a FRESH instance; nothing in this module
32
+ * may call `.test()` or `.exec()` on a `/g` regex it did not just create.
33
+ *
34
+ * Strict, anchored to its own literal shape, ASCII-only. Group 1 is the id.
35
+ */
36
+ export declare function pasteTokenRe(): RegExp;
37
+ export declare function allocatePasteId(): number;
38
+ /** Test-only reset. Never called from application code. */
39
+ export declare function resetPasteIdsForTest(): void;
40
+ export declare function makePasteRecord(id: number, text: string): PasteRecord;
41
+ /**
42
+ * `[Pasted text #3 +218 lines]`, or `[Pasted text #4 +1204 chars]` for a paste
43
+ * that is one very long line.
44
+ *
45
+ * "+1 lines" is not a sentence, and for a one-liner the number the user cares
46
+ * about is its length rather than its line count.
47
+ */
48
+ export declare function formatPasteToken(record: PasteRecord): string;
49
+ /** Above EITHER inline bound the paste is collapsed into a token (G3 / G4). */
50
+ export declare function shouldCollapse(text: string): boolean;
51
+ interface TokenSpan {
52
+ readonly start: number;
53
+ readonly end: number;
54
+ readonly id: number;
55
+ }
56
+ /** Every token in the buffer, in order. */
57
+ export declare function tokenSpans(buffer: string): TokenSpan[];
58
+ /** The token containing or touching `index`, or `null`. */
59
+ export declare function tokenAt(buffer: string, index: number): TokenSpan | null;
60
+ /**
61
+ * Move a caret that landed STRICTLY INSIDE a token to its nearer edge (D-8).
62
+ *
63
+ * An index at either edge, or outside every token, is returned unchanged -- so
64
+ * this is a no-op on a buffer with no tokens, which is every buffer today.
65
+ */
66
+ export declare function snapOutOfToken(buffer: string, cursor: number): number;
67
+ /**
68
+ * Grow `[from, to)` so it covers every token it partially overlaps (G5).
69
+ *
70
+ * This is what makes `Backspace` immediately after `...lines]` remove the whole
71
+ * token in ONE keystroke, and what stops `Ctrl+W` / `Ctrl+U` / `Ctrl+K` leaving
72
+ * half a token -- and therefore an unparseable label -- behind.
73
+ */
74
+ export declare function expandRangeOverTokens(buffer: string, from: number, to: number): {
75
+ from: number;
76
+ to: number;
77
+ };
78
+ /**
79
+ * Replace every token whose id is present in `pastes`; leave the rest verbatim.
80
+ *
81
+ * A user who literally TYPES `[Pasted text #3 +9 lines]` while paste #3 exists in
82
+ * the same draft will have it expanded. That is bounded (ids are per process, the
83
+ * map is per draft) and harmless (they get text that is already in their own
84
+ * draft), and the alternative -- an unguessable nonce in the label -- would trade
85
+ * a case nobody hits for a label nobody can read.
86
+ */
87
+ export declare function expandPastes(buffer: string, pastes: ReadonlyMap<number, PasteRecord>): string;
88
+ /** Ids still referenced by the buffer -- the input to I-7's prune. */
89
+ export declare function referencedIds(buffer: string): Set<number>;
90
+ export {};
@@ -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';