@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.
- package/CHANGELOG.md +142 -0
- package/README.md +203 -13
- package/dist/agent/controller.d.ts +158 -2
- package/dist/agent/controller.js +400 -1
- package/dist/agent/reducer.d.ts +102 -19
- package/dist/agent/reducer.js +162 -71
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/agent/usage.d.ts +34 -0
- package/dist/agent/usage.js +43 -0
- package/dist/cli.js +132 -23
- package/dist/commands/builtins.js +101 -1
- package/dist/compaction/child.js +5 -0
- package/dist/compaction/command.js +14 -1
- package/dist/compaction/compactor.d.ts +94 -24
- package/dist/compaction/compactor.js +247 -98
- package/dist/compaction/context-command.d.ts +33 -0
- package/dist/compaction/context-command.js +166 -0
- package/dist/compaction/meter.d.ts +251 -0
- package/dist/compaction/meter.js +428 -0
- package/dist/compaction/pressure.d.ts +6 -0
- package/dist/compaction/pressure.js +4 -0
- package/dist/compaction/types.d.ts +77 -0
- package/dist/compaction/wiring.d.ts +56 -0
- package/dist/compaction/wiring.js +107 -25
- package/dist/config/cli-commands.js +5 -0
- package/dist/config/env.d.ts +1 -1
- package/dist/config/env.js +63 -2
- package/dist/config/load.d.ts +28 -0
- package/dist/config/load.js +83 -1
- package/dist/config/schema.d.ts +129 -0
- package/dist/config/schema.js +69 -0
- package/dist/config/store.js +47 -1
- package/dist/exec/events.d.ts +138 -1
- package/dist/exec/events.js +42 -0
- package/dist/exec/index.js +43 -6
- package/dist/exec/progress-reporter.d.ts +23 -0
- package/dist/exec/progress-reporter.js +88 -0
- package/dist/exec/runner.d.ts +34 -0
- package/dist/exec/runner.js +137 -25
- package/dist/fast/reviewer.d.ts +19 -1
- package/dist/fast/reviewer.js +22 -0
- package/dist/fast/types.d.ts +15 -0
- package/dist/fast/wiring.d.ts +10 -1
- package/dist/fast/wiring.js +18 -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/todo/limits.d.ts +4 -17
- package/dist/todo/limits.js +4 -17
- package/dist/todo/panel-rows.d.ts +8 -6
- package/dist/todo/panel-rows.js +19 -6
- 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 +330 -69
- package/dist/ui/AutocompletePopup.d.ts +3 -1
- package/dist/ui/AutocompletePopup.js +9 -11
- package/dist/ui/BottomStatusRow.d.ts +1 -1
- package/dist/ui/Composer.d.ts +56 -1
- package/dist/ui/Composer.js +50 -6
- package/dist/ui/PromptInput.d.ts +52 -3
- package/dist/ui/PromptInput.js +201 -55
- package/dist/ui/StatusBar.d.ts +40 -15
- package/dist/ui/StatusBar.js +51 -9
- package/dist/ui/TeamPanel.d.ts +3 -1
- package/dist/ui/TeamPanel.js +13 -9
- package/dist/ui/TodoPanel.d.ts +5 -5
- package/dist/ui/TodoPanel.js +56 -24
- package/dist/ui/Transcript.js +29 -1
- package/dist/ui/UpdateLine.d.ts +1 -1
- package/dist/ui/capabilities.js +5 -5
- 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/exit-snapshot.d.ts +9 -0
- package/dist/ui/layout/ScrollViewport.d.ts +1 -1
- package/dist/ui/layout/autocomplete.d.ts +18 -0
- package/dist/ui/layout/autocomplete.js +26 -0
- package/dist/ui/layout/budget.d.ts +34 -6
- package/dist/ui/layout/budget.js +32 -7
- package/dist/ui/layout/rail.d.ts +4 -53
- package/dist/ui/layout/rail.js +10 -62
- package/dist/ui/layout/team-panel.d.ts +18 -0
- package/dist/ui/layout/team-panel.js +22 -0
- package/dist/ui/layout/todo-layout.d.ts +24 -0
- package/dist/ui/layout/todo-layout.js +27 -0
- 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 +55 -2
- 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
|
@@ -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 {};
|
|
@@ -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
|
+
}
|
package/dist/ui/screen.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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-
|
|
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';
|