@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.
- package/CHANGELOG.md +105 -0
- package/README.md +95 -2
- package/dist/agent/controller.d.ts +119 -0
- package/dist/agent/controller.js +275 -1
- package/dist/agent/reducer.d.ts +69 -0
- package/dist/agent/reducer.js +100 -0
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +84 -22
- package/dist/commands/builtins.js +92 -1
- package/dist/compaction/child.js +5 -0
- package/dist/compaction/command.js +14 -1
- package/dist/compaction/compactor.d.ts +34 -0
- package/dist/compaction/compactor.js +158 -37
- package/dist/compaction/types.d.ts +36 -0
- package/dist/compaction/wiring.d.ts +9 -0
- package/dist/compaction/wiring.js +22 -1
- package/dist/config/env.d.ts +1 -1
- package/dist/config/env.js +48 -1
- package/dist/config/load.d.ts +11 -0
- package/dist/config/load.js +39 -1
- package/dist/config/schema.d.ts +85 -0
- package/dist/config/schema.js +33 -0
- package/dist/config/store.js +21 -1
- package/dist/input/limits.d.ts +72 -0
- package/dist/input/limits.js +63 -0
- package/dist/input/paste-parse.d.ts +71 -0
- package/dist/input/paste-parse.js +95 -0
- package/dist/input/stdin-filter.d.ts +81 -0
- package/dist/input/stdin-filter.js +379 -0
- package/dist/logging/install.d.ts +2 -0
- package/dist/logging/install.js +53 -1
- package/dist/proc/classify.d.ts +52 -0
- package/dist/proc/classify.js +143 -0
- package/dist/proc/kill-tree.d.ts +42 -0
- package/dist/proc/kill-tree.js +119 -0
- package/dist/proc/limits.d.ts +84 -0
- package/dist/proc/limits.js +84 -0
- package/dist/proc/log-ring.d.ts +68 -0
- package/dist/proc/log-ring.js +102 -0
- package/dist/proc/prompt.d.ts +24 -0
- package/dist/proc/prompt.js +49 -0
- package/dist/proc/readiness.d.ts +102 -0
- package/dist/proc/readiness.js +219 -0
- package/dist/proc/supervisor.d.ts +103 -0
- package/dist/proc/supervisor.js +465 -0
- package/dist/proc/types.d.ts +175 -0
- package/dist/proc/types.js +15 -0
- package/dist/session/persist.js +36 -1
- package/dist/tools/bash-tool.d.ts +51 -2
- package/dist/tools/bash-tool.js +321 -51
- package/dist/tools/fs-tools.d.ts +17 -0
- package/dist/tools/index.d.ts +55 -2
- package/dist/tools/index.js +39 -1
- package/dist/tools/proc-tools.d.ts +25 -0
- package/dist/tools/proc-tools.js +102 -0
- package/dist/ui/App.d.ts +15 -2
- package/dist/ui/App.js +256 -7
- package/dist/ui/Composer.d.ts +54 -1
- package/dist/ui/Composer.js +50 -6
- package/dist/ui/PromptInput.d.ts +49 -3
- package/dist/ui/PromptInput.js +184 -52
- package/dist/ui/StatusBar.d.ts +19 -0
- package/dist/ui/StatusBar.js +5 -2
- package/dist/ui/Transcript.js +29 -1
- package/dist/ui/composer-limits.d.ts +31 -0
- package/dist/ui/composer-limits.js +50 -0
- package/dist/ui/composer-rows.d.ts +73 -0
- package/dist/ui/composer-rows.js +201 -0
- package/dist/ui/editor-reducer.d.ts +37 -0
- package/dist/ui/editor-reducer.js +88 -12
- package/dist/ui/entries/ServiceCard.d.ts +66 -0
- package/dist/ui/entries/ServiceCard.js +116 -0
- package/dist/ui/entries/UserEntry.d.ts +17 -3
- package/dist/ui/entries/UserEntry.js +34 -5
- package/dist/ui/layout/budget.d.ts +34 -6
- package/dist/ui/layout/budget.js +32 -7
- package/dist/ui/layout/virtual-window.js +51 -1
- package/dist/ui/overlays/HelpOverlay.js +3 -0
- package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
- package/dist/ui/overlays/QuestionOverlay.js +5 -1
- package/dist/ui/overlays/SettingsScreen.js +14 -1
- package/dist/ui/paste-frames.d.ts +87 -0
- package/dist/ui/paste-frames.js +112 -0
- package/dist/ui/paste-tokens.d.ts +90 -0
- package/dist/ui/paste-tokens.js +147 -0
- package/dist/ui/screen.d.ts +16 -0
- package/dist/ui/screen.js +45 -2
- package/dist/ui/selection/selection-controller.d.ts +1 -1
- package/dist/ui/transcript-text.js +49 -0
- package/dist/ui/use-wheel-routing.d.ts +1 -1
- package/package.json +2 -2
- package/dist/input/stdin-mouse-filter.d.ts +0 -47
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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;
|
package/dist/ui/layout/budget.js
CHANGED
|
@@ -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
|
-
|
|
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 ?
|
|
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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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 {};
|