@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
@@ -30,11 +30,26 @@ export interface ChromeBudget {
30
30
  /** Brand bar. Constant 1 in full-screen — this is what makes A-3 hold. */
31
31
  header: number;
32
32
  toast: number;
33
- /** Round border (2) + input row (1), plus the hint row when it fits. */
33
+ /**
34
+ * Round border (2) + the DRAFT's own rows, plus the hint row when it fits.
35
+ *
36
+ * The draft term used to be the constant 1 that the `3` / `4` below encode.
37
+ * That constant is what made a pasted 200-line draft draw the transcript 197
38
+ * rows shorter than every consumer of `viewportRows` believed -- the trap
39
+ * `BottomStatusRow.tsx:5-27` already documents at 1/200th the magnitude.
40
+ */
34
41
  composer: number;
35
42
  status: number;
36
43
  }
37
- export declare function chromeBudget(rows: number): ChromeBudget;
44
+ /**
45
+ * The composer's draft rows, clamped to what this terminal height allows.
46
+ *
47
+ * Exported so the reporter and the budget clamp with ONE function: I-8 requires
48
+ * the rendered row count and the number handed to `viewportRows` to be the same
49
+ * number, and two clamps are how they stop being.
50
+ */
51
+ export declare function clampDraftRows(rows: number, draftRows: number): number;
52
+ export declare function chromeBudget(rows: number, draftRows?: number): ChromeBudget;
38
53
  /**
39
54
  * Rows available to the viewport at a given terminal height.
40
55
  *
@@ -42,8 +57,21 @@ export declare function chromeBudget(rows: number): ChromeBudget;
42
57
  * that argument is the guarantee that the first submitted message cannot make
43
58
  * the layout jump (A-4). Do not add it back.
44
59
  *
45
- * Non-decreasing in `rows`: the one step where chrome grows (the hint row at
46
- * `HINT_MIN_ROWS`) coincides with `rows` growing by one, so the net change is 0
47
- * rather than -1.
60
+ * `draftRows` IS NOT THAT ARGUMENT, and the distinction is the whole of D-12.
61
+ * A-4 forbids making the budget depend on TRANSCRIPT content, so that the first
62
+ * submitted message cannot move the layout. `draftRows` depends only on what the
63
+ * user is typing right now, and its whole purpose is that the jump is ALREADY
64
+ * HAPPENING -- Yoga takes the rows out of the transcript whether or not this
65
+ * function was told. `App` adds the hysteresis (grow now, shrink one tick later)
66
+ * that keeps it from becoming per-keystroke jitter.
67
+ *
68
+ * It DEFAULTS TO 1, i.e. to the constant this function used to assume, so every
69
+ * pre-existing caller and every pre-existing assertion is byte-identical -- and
70
+ * `viewportRows.length` stays 1, because a parameter with a default does not
71
+ * count toward it.
72
+ *
73
+ * Non-decreasing in `rows` at a fixed `draftRows = 1`: the one step where chrome
74
+ * grows (the hint row at `HINT_MIN_ROWS`) coincides with `rows` growing by one,
75
+ * so the net change is 0 rather than -1.
48
76
  */
49
- export declare function viewportRows(rows: number): number;
77
+ export declare function viewportRows(rows: number, draftRows?: number): number;
@@ -24,14 +24,26 @@
24
24
  * make this comment a true-sounding statement about a row that is gone (AC-12,
25
25
  * and the `wheel-scrolls-transcript-only` RV-3 lesson).
26
26
  */
27
+ import { draftMaxRows } from '../composer-limits.js';
27
28
  import { frameHeight, MIN_FULLSCREEN_ROWS } from './frame.js';
28
29
  /** Below this many rows the composer's hint line is dropped to buy back a row. */
29
30
  export const HINT_MIN_ROWS = 20;
30
- export function chromeBudget(rows) {
31
+ /**
32
+ * The composer's draft rows, clamped to what this terminal height allows.
33
+ *
34
+ * Exported so the reporter and the budget clamp with ONE function: I-8 requires
35
+ * the rendered row count and the number handed to `viewportRows` to be the same
36
+ * number, and two clamps are how they stop being.
37
+ */
38
+ export function clampDraftRows(rows, draftRows) {
39
+ const wanted = Number.isFinite(draftRows) ? Math.floor(draftRows) : 1;
40
+ return Math.max(1, Math.min(wanted, draftMaxRows(rows)));
41
+ }
42
+ export function chromeBudget(rows, draftRows = 1) {
31
43
  return {
32
44
  header: 1,
33
45
  toast: 1,
34
- composer: rows >= HINT_MIN_ROWS ? 4 : 3,
46
+ composer: 2 + clampDraftRows(rows, draftRows) + (rows >= HINT_MIN_ROWS ? 1 : 0),
35
47
  status: 1,
36
48
  };
37
49
  }
@@ -42,13 +54,26 @@ export function chromeBudget(rows) {
42
54
  * that argument is the guarantee that the first submitted message cannot make
43
55
  * the layout jump (A-4). Do not add it back.
44
56
  *
45
- * Non-decreasing in `rows`: the one step where chrome grows (the hint row at
46
- * `HINT_MIN_ROWS`) coincides with `rows` growing by one, so the net change is 0
47
- * rather than -1.
57
+ * `draftRows` IS NOT THAT ARGUMENT, and the distinction is the whole of D-12.
58
+ * A-4 forbids making the budget depend on TRANSCRIPT content, so that the first
59
+ * submitted message cannot move the layout. `draftRows` depends only on what the
60
+ * user is typing right now, and its whole purpose is that the jump is ALREADY
61
+ * HAPPENING -- Yoga takes the rows out of the transcript whether or not this
62
+ * function was told. `App` adds the hysteresis (grow now, shrink one tick later)
63
+ * that keeps it from becoming per-keystroke jitter.
64
+ *
65
+ * It DEFAULTS TO 1, i.e. to the constant this function used to assume, so every
66
+ * pre-existing caller and every pre-existing assertion is byte-identical -- and
67
+ * `viewportRows.length` stays 1, because a parameter with a default does not
68
+ * count toward it.
69
+ *
70
+ * Non-decreasing in `rows` at a fixed `draftRows = 1`: the one step where chrome
71
+ * grows (the hint row at `HINT_MIN_ROWS`) coincides with `rows` growing by one,
72
+ * so the net change is 0 rather than -1.
48
73
  */
49
- export function viewportRows(rows) {
74
+ export function viewportRows(rows, draftRows = 1) {
50
75
  if (!Number.isFinite(rows) || rows < MIN_FULLSCREEN_ROWS)
51
76
  return 0;
52
- const c = chromeBudget(rows);
77
+ const c = chromeBudget(rows, draftRows);
53
78
  return Math.max(0, frameHeight(rows) - c.header - c.toast - c.composer - c.status);
54
79
  }
@@ -35,6 +35,7 @@
35
35
  import { FAST_LIMITS } from '../../fast/limits.js';
36
36
  import { COMPACTION_LIMITS } from '../../compaction/limits.js';
37
37
  import { GUTTER_WIDTH } from './Gutter.js';
38
+ import { USER_ENTRY_MAX_ROWS } from '../composer-limits.js';
38
39
  export const VIRTUAL_LIMITS = {
39
40
  /**
40
41
  * Entries kept mounted on each side of the visible band.
@@ -123,6 +124,22 @@ export function entryRevision(entry) {
123
124
  */
124
125
  case 'compaction':
125
126
  return `c${entry.live ? 1 : 0}.${entry.summary?.length ?? 0}.${entry.mode}.${entry.applied ? 1 : 0}`;
127
+ /**
128
+ * `rowsSeen`, AND NEVER `rows.length` (background-service-supervision P0-3).
129
+ *
130
+ * The service tail is a FIXED-SIZE RING: it evicts its oldest row while
131
+ * appending a new one, so the joined length is unchanged while the content is
132
+ * not. That is verbatim the NON-APPEND mutation I-L3-1 above says must extend
133
+ * the revision, and it is the same trap — and the same fix — as the `tool`
134
+ * branch's `liveSeq` two cases up. A length term would go on matching while
135
+ * the card changed underneath it, and the card would freeze at a stale height
136
+ * AND a stale rendered subtree with nothing anywhere reporting it.
137
+ *
138
+ * `status` and the presence of a URL are in because both change the rows the
139
+ * card draws; `exitCode` is in because the terminal row names it.
140
+ */
141
+ case 'service':
142
+ return `v${entry.status}.${entry.rowsSeen}.${entry.url ? 1 : 0}.${entry.exitCode ?? -1}.${entry.terminal ? 1 : 0}`;
126
143
  default:
127
144
  return 'x';
128
145
  }
@@ -164,6 +181,10 @@ const TOOL_COLLAPSED_LINES = 8;
164
181
  const DIFF_COLLAPSED_LINES = 12;
165
182
  /** Live tail rows in `ToolCard`; duplicated as a NUMBER, for the same reason. */
166
183
  const TOOL_LIVE_TAIL_ROWS = 8;
184
+ /** Tail rows a COLLAPSED `ServiceCard` draws; duplicated for the same reason. */
185
+ const SERVICE_COLLAPSED_TAIL_ROWS = 2;
186
+ /** Tail rows an EXPANDED `ServiceCard` draws; the snapshot cap (8). */
187
+ const SERVICE_EXPANDED_TAIL_ROWS = 8;
167
188
  /**
168
189
  * Pure UPPER-BOUND estimate of an entry's height, used until a real measurement
169
190
  * exists.
@@ -181,7 +202,14 @@ export function estimateEntryRows(entry, cols, density, expanded, thinkingVisibl
181
202
  const separation = density === 'comfortable' ? 1 : 0;
182
203
  switch (entry.kind) {
183
204
  case 'user':
184
- return separation + wrappedRows(entry.text, usable);
205
+ // MIRRORS `UserEntry`'s RENDER CAP (+1 for the `... +N more lines` tail).
206
+ // THESE TWO NUMBERS ARE ONE NUMBER (I-13). Capping the renderer alone
207
+ // would leave this function believing a 5 000-line message is 5 000 rows
208
+ // tall -- and unlike an ordinary over-estimate that failure does NOT
209
+ // self-correct: this file's own note above records that "an entry
210
+ // estimated at 400 rows is exactly the one that never gets mounted", and
211
+ // an unmounted entry is never measured.
212
+ return (separation + Math.min(wrappedRows(entry.text, usable), USER_ENTRY_MAX_ROWS + 1));
185
213
  case 'notice':
186
214
  return separation + wrappedRows(entry.text, usable);
187
215
  case 'assistant': {
@@ -277,6 +305,28 @@ export function estimateEntryRows(entry, cols, density, expanded, thinkingVisibl
277
305
  const footer = lines > body || (expanded && lines > COMPACTION_LIMITS.cardTextRows) ? 1 : 0;
278
306
  return separation + 1 + 1 + body + footer;
279
307
  }
308
+ /**
309
+ * THE THIRD SWITCH, and adding the case to only one of this file's two
310
+ * yields a transcript whose scroll arithmetic disagrees with what it drew —
311
+ * drift rather than an error (P0-3 / RV-13). Both `default` branches swallow
312
+ * an unknown kind silently, and `virtual-window.test.ts` hand-enumerates
313
+ * kinds, so neither omission turns anything red on its own.
314
+ *
315
+ * A terminal record is exactly one row. A live card is the status row, the
316
+ * URL row when there is one, and the tail — counted, not wrapped, which is
317
+ * legitimate only because every row is `wrap="truncate"` AND clipped at
318
+ * `LIVE_ROW_MAX_CHARS` on the way into the ring, the same coupling the
319
+ * `tool` branch above records for itself.
320
+ */
321
+ case 'service': {
322
+ if (entry.terminal)
323
+ return separation + 1;
324
+ const url = entry.url ? 1 : 0;
325
+ const tail = expanded
326
+ ? Math.min(entry.rows.length, SERVICE_EXPANDED_TAIL_ROWS)
327
+ : Math.min(entry.rows.length, SERVICE_COLLAPSED_TAIL_ROWS);
328
+ return separation + 1 + url + tail;
329
+ }
280
330
  default:
281
331
  return separation + 1;
282
332
  }
@@ -26,6 +26,9 @@ function keyRows(times) {
26
26
  // startup notice is the other place, and it scrolls away.
27
27
  ['Drag (left button)', 'Select text on screen; releasing copies it'],
28
28
  ['Up / Down', 'Prompt history (empty input)'],
29
+ // Pasting is the one affordance here with no key of its own, so the only
30
+ // place a user can learn what happened to their 218 lines is this row.
31
+ ['Paste', 'Inserted as-is up to 6 lines; larger becomes [Pasted text #N] (--no-paste)'],
29
32
  ];
30
33
  }
31
34
  const COMMANDS = [
@@ -27,6 +27,7 @@ import { Text, useInput } from 'ink';
27
27
  import { pickGlyphs } from '../glyphs.js';
28
28
  import { OverlayFrame } from '../layout/OverlayFrame.js';
29
29
  import { wrapToRows } from '../layout/wrap-rows.js';
30
+ import { stripPasteFrames } from '../paste-frames.js';
30
31
  /** The frame's border plus its one-column padding on each side. */
31
32
  const FRAME_CHROME_COLS = 4;
32
33
  export function PlanReviewOverlay({ plan, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onVerdict, }) {
@@ -55,8 +56,15 @@ export function PlanReviewOverlay({ plan, maxRows, cols, scrollOffset, onScrollC
55
56
  setFeedback(feedback.slice(0, -1));
56
57
  return;
57
58
  }
58
- if (input && !key.ctrl && !key.meta && !key.tab)
59
- setFeedback(feedback + input);
59
+ // `stripPasteFrames`, NOT `input` (I-12 / P0-2). This is the THIRD consumer
60
+ // that appends `input` to a string; the design's table listed it as
61
+ // "key-driven only, inert", which was true of the version that had no
62
+ // free-text field. Revision feedback is exactly the kind of thing a user
63
+ // pastes — a failing test, a stack trace — and a framed paste appended
64
+ // verbatim would put NULs into text the model then reads.
65
+ if (input && !key.ctrl && !key.meta && !key.tab) {
66
+ setFeedback(feedback + stripPasteFrames(input));
67
+ }
60
68
  return;
61
69
  }
62
70
  if (input === 'a' || input === 'A')
@@ -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 {};