@aragon-agent/cli 0.6.27 → 0.6.28
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 +36 -0
- package/README.md +2 -2
- package/dist/input/control-sequences.d.ts +71 -0
- package/dist/input/control-sequences.js +222 -0
- package/dist/input/paste-parse.d.ts +21 -9
- package/dist/input/paste-parse.js +23 -11
- package/dist/input/stdin-filter.js +56 -5
- package/dist/tools/terminal-output.d.ts +8 -0
- package/dist/tools/terminal-output.js +10 -7
- package/dist/ui/display-tab.d.ts +51 -0
- package/dist/ui/display-tab.js +82 -0
- package/dist/ui/editor-navigation.d.ts +11 -1
- package/dist/ui/editor-navigation.js +14 -3
- package/dist/ui/entries/QueuedEntry.js +4 -1
- package/dist/ui/entries/UserEntry.js +8 -1
- package/dist/ui/layout/virtual-window.js +44 -11
- package/dist/ui/paste-frames.d.ts +5 -1
- package/dist/ui/paste-frames.js +7 -2
- package/dist/ui/transcript-text.js +11 -2
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -77,6 +77,42 @@ were written.
|
|
|
77
77
|
|
|
78
78
|
### Fixed
|
|
79
79
|
|
|
80
|
+
- **A large paste arrived as runs of digits the moment it was sent - on screen,
|
|
81
|
+
in the model's input, in the prompt history and in the session file.**
|
|
82
|
+
`sanitisePaste` deleted the C0/C1 *introducer byte* and kept the parameter
|
|
83
|
+
bytes, so `ESC[38;2;18;52;86m` reached the transcript as the literal text
|
|
84
|
+
`[38;2;18;52;86m`. The composer collapses a large paste into
|
|
85
|
+
`[Pasted text #N +20 lines]`, so nothing was visible until submit expanded
|
|
86
|
+
it: a measured 20-line coloured log produced 80 runs of digits, and the same
|
|
87
|
+
string was what `/context`, the ↑ history and the session checkpoint stored.
|
|
88
|
+
Cleaning is now SEQUENCE-based and has one owner (new
|
|
89
|
+
`input/control-sequences.ts`, ASCII-only, no new dependency): a complete
|
|
90
|
+
escape goes as a unit, a truncated one loses its introducer and parameters
|
|
91
|
+
but never the character after them, an unterminated OSC/DCS/APC/PM/SOS goes
|
|
92
|
+
to the end of its line and never across it, and bare text that merely looks
|
|
93
|
+
like a sequence body (`[38;2m` with no ESC) is left exactly as it is.
|
|
94
|
+
`tools/terminal-output.ts`'s `stripAnsi` becomes a thin wrapper over the same
|
|
95
|
+
syntax, so a tool row and a paste cannot drift apart (both directions are
|
|
96
|
+
pinned by a cross-check test, and `terminal-output.test.ts` is unchanged).
|
|
97
|
+
A **single-line** paste carrying ESC - which `classifyChunk` sent down the
|
|
98
|
+
keystroke path with no cleaning at all - is now framed and cleaned like any
|
|
99
|
+
other paste; measured on the real component that path either put a live ESC
|
|
100
|
+
plus `[38;2;18;52;86m` into the draft or threw an uncaught `TypeError` inside
|
|
101
|
+
Ink's key parser. win32-input-mode records inside a frame are translated
|
|
102
|
+
rather than deleted, so a `?9001h` paste yields its characters instead of 15
|
|
103
|
+
visible digits per key (or, with cleaning but no translation, nothing at all).
|
|
104
|
+
- **A pasted TAB-indented block or TSV table lined up differently before and
|
|
105
|
+
after sending.** One byte had three readings: the composer painted
|
|
106
|
+
four-column tab stops, the transcript handed the raw `\t` to the terminal
|
|
107
|
+
(eight), and the exit replay counted code units. New `ui/display-tab.ts`
|
|
108
|
+
expands a TAB by DISPLAY column (`string-width`, so a `CJK<TAB>X` line lands
|
|
109
|
+
where the terminal paints it); the composer's stop is the terminal's eight, and
|
|
110
|
+
`UserEntry`, `QueuedEntry` and the exit replay expand at paint time only -
|
|
111
|
+
the stored payload keeps its `\t`, so the model still receives the original
|
|
112
|
+
indentation. `wrappedRows` counts display columns too (with an ASCII fast
|
|
113
|
+
path, without which a 10 000-entry transcript timed out), because an estimate
|
|
114
|
+
that reads a TAB as one column keeps the row short while the painted row
|
|
115
|
+
grows - the under-estimate that file names as the unsafe direction.
|
|
80
116
|
- **`Ctrl+L` opened a window that failed with a PowerShell `ParserError` on
|
|
81
117
|
Windows.** The script handed to `-EncodedCommand` was the argv joined with
|
|
82
118
|
quotes but with no call operator, and PowerShell reads a quoted string in command
|
package/README.md
CHANGED
|
@@ -539,7 +539,7 @@ completes, `Up` / `Down` moves the selection, `Esc` closes it.
|
|
|
539
539
|
| `Ctrl+W`, `Alt+Backspace` | Delete the previous word |
|
|
540
540
|
| `Ctrl+U` / `Ctrl+K` | Kill to line start / end |
|
|
541
541
|
| `Up` / `Down` | 按视觉行移动;空草稿或正在浏览历史时,才在边界召回历史,避免覆盖新草稿 |
|
|
542
|
-
| Paste | Inserted
|
|
542
|
+
| Paste | Inserted up to 6 lines / 400 chars; larger collapses to `[Pasted text #1 +15 lines]`, which deletes as one unit and expands in full when you send. Escape sequences are removed as sequences (so a coloured log arrives as its text, not as `[38;2;18;52;86m`), and a TAB is kept in the payload but laid out at the terminal's 8-column tab stops, so the draft and the echoed message line up. `--no-paste` restores the pre-0.6.3 behaviour |
|
|
543
543
|
| `?` | Open help (empty input) |
|
|
544
544
|
|
|
545
545
|
In **full-screen mode**, PgUp/PgDn, Shift+Up/Down and the right-edge scrollbar
|
|
@@ -2219,7 +2219,7 @@ env / `.env` → CLI flags**.
|
|
|
2219
2219
|
| `planModeDefault` | `false` | Start every session in plan mode. `--no-plan` still overrides it for a single run. |
|
|
2220
2220
|
| `planModeMaxAskRounds` | `4` | `ask_user` rounds allowed per user message, clamped to `[1, 10]`. The counter resets on your next message; steering does **not** reset it. |
|
|
2221
2221
|
| `planModeHumanTimeoutMs` | `1800000` | Ceiling on a single question / plan wait, clamped to `[60000, 7200000]`. Thirty minutes is far outside "went to get coffee", but a ceiling has to exist so an unattended terminal cannot wedge a CI job forever. |
|
|
2222
|
-
| `paste` | `true` | Recognise pasted text before Ink's key parser sees it: line breaks become buffer newlines instead of fifteen separate messages,
|
|
2222
|
+
| `paste` | `true` | Recognise pasted text before Ink's key parser sees it: line breaks become buffer newlines instead of fifteen separate messages, escape SEQUENCES are removed whole (deleting only the introducer byte is what put `[38;2;18;52;86m` on screen and in the model's input) as are stray control bytes, and a paste above 6 lines or 400 characters collapses to a one-line placeholder that expands when you send. `false` treats pasted bytes as keystrokes, which is what 0.6.2 did. |
|
|
2223
2223
|
| `historyEnabled` | `true` | Record submitted prompts to `prompt-history.jsonl` for `↑` recall. Setting it to `false` stops new writes; it does not delete what is already stored (that is `aragon history clear --yes`) and does not stop reading. See [Prompt history](#prompt-history). |
|
|
2224
2224
|
| `team.enabled` | `true` | Master switch for team subagents. `false` means the `task` tool is never registered — `/team on` in such a session saves the setting for next launch and says so, rather than advertising a tool that is not there. |
|
|
2225
2225
|
| `team.maxSubagents` | `5` | Subagents per dispatch, clamped to `[1, 10]`. The 10 is the hard ceiling and is enforced twice — on the config value and again on what the model asks for — so hand-editing this file cannot raise it. |
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-SEQUENCE removal for text that crosses a boundary where a terminal
|
|
3
|
+
* escape must never become content (paste-control-sequence-residue, fix A'-1).
|
|
4
|
+
*
|
|
5
|
+
* WHY A DELETE-THE-WHOLE-SEQUENCE RULE EXISTS. Deleting only the introducer
|
|
6
|
+
* byte turns an escape into visible text: `ESC [ 3 8 ; 2 m` loses its ESC and
|
|
7
|
+
* leaves `[38;2m`, which is a run of digits and semicolons somebody then reads
|
|
8
|
+
* on screen and sends to the model. The bytes were never content; the sequence
|
|
9
|
+
* has to go as a unit.
|
|
10
|
+
*
|
|
11
|
+
* WHY IT IS NOT `tools/terminal-output.ts`'s REGEXES MOVED HERE. Those four
|
|
12
|
+
* regexes answer "what does this look like" for a terminal row that is still
|
|
13
|
+
* being written, and they are fed text whose unfinished tail has ALREADY been
|
|
14
|
+
* cut off and parked in a carry by the caller (`sanitizeChunk`,
|
|
15
|
+
* `toDisplayRow`). Pasted text is one-shot: there is no next chunk, so a
|
|
16
|
+
* truncated sequence needs a rule of its own. Measured against the stream
|
|
17
|
+
* version of the same input, `ESC [ 3 8 ; 2 ; 1 8 ; 5 2 ; 8 6` followed by
|
|
18
|
+
* prose loses the first letter of the prose (`n`), and an unterminated OSC
|
|
19
|
+
* leaves its whole body behind.
|
|
20
|
+
*
|
|
21
|
+
* THE FOUR DECISIONS THIS MODULE ENCODES:
|
|
22
|
+
*
|
|
23
|
+
* 1. A sequence introduced by a real introducer byte (`0x1B`, `0x9B`, `0x9D`,
|
|
24
|
+
* ...) is removed as a unit; bare text that merely LOOKS like the inside of
|
|
25
|
+
* one (`[38;2;18;52;86m` typed or pasted without an ESC) is left exactly as
|
|
26
|
+
* it is, character for character.
|
|
27
|
+
* 2. A string-type sequence (OSC / DCS / SOS / PM / APC) runs to BEL or ST, and
|
|
28
|
+
* an unterminated one is removed UP TO THE END OF ITS LINE. It never crosses
|
|
29
|
+
* `\n` and never guesses at a terminator: a truncated sequence is the most
|
|
30
|
+
* common shape in a clipboard, and the only safe reading of it is "the rest
|
|
31
|
+
* of this line was never text".
|
|
32
|
+
* 3. A truncated CSI loses its introducer and its parameter bytes and NOTHING
|
|
33
|
+
* ELSE. `ESC [ 3 1 m` split mid-parameter is parameters, not prose, but the
|
|
34
|
+
* byte after them is the user's own character and must survive.
|
|
35
|
+
* 4. A C1 CSI (`0x9B`) with no parameter byte at all is treated as the stray
|
|
36
|
+
* control byte it is most likely to be rather than as a sequence, because a
|
|
37
|
+
* lone `0x9B` followed by one letter is otherwise indistinguishable from a
|
|
38
|
+
* non-UTF-8 file's stray byte followed by content -- and consuming that
|
|
39
|
+
* letter would be the very data loss this module exists to prevent.
|
|
40
|
+
*
|
|
41
|
+
* Pure, ASCII-only, React-free, Ink-free, and TOTAL: it never throws and it
|
|
42
|
+
* never reorders or mangles the bytes it keeps. No new dependency.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Remove every escape/control sequence, keeping all other bytes in order.
|
|
46
|
+
*
|
|
47
|
+
* KEEPS `\t`, `\n` AND `\r`: they are line structure, and `\t` inside pasted
|
|
48
|
+
* indentation is content the model is supposed to receive. Stray C0/C1 bytes
|
|
49
|
+
* (and DEL) are NOT this function's job -- they are removed by the caller that
|
|
50
|
+
* wants them gone, so one rule has one owner.
|
|
51
|
+
*/
|
|
52
|
+
export declare function stripControlSequences(text: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* True when `text` carries a sequence a KEYBOARD cannot physically produce.
|
|
55
|
+
*
|
|
56
|
+
* Used at the keystroke branch of the stdin filter: a single-line paste that
|
|
57
|
+
* carries ESC fails `classifyChunk`'s two rules (no line break, and
|
|
58
|
+
* `HAS_CONTROL_EXCEPT_TAB`) and would otherwise reach Ink as raw bytes --
|
|
59
|
+
* either typing `[38;2;18;52;86m` with a live ESC into the draft, or throwing
|
|
60
|
+
* an uncaught `TypeError` inside Ink's key parser.
|
|
61
|
+
*
|
|
62
|
+
* THREE THINGS MUST NOT MATCH, and the shapes below are why:
|
|
63
|
+
* - SGR mouse reports (`ESC [ < 0 ; 1 ; 2 m`) are stripped by `splitMouseEvents`
|
|
64
|
+
* BEFORE this test, and `<` is not a parameter byte, so the SGR rule cannot
|
|
65
|
+
* see them anyway.
|
|
66
|
+
* - Arrow / function / Home / End keys (`ESC [ A`, `ESC [ 1 ; 5 D`, `ESC [ 3 ~`,
|
|
67
|
+
* `ESC [ Z`) never end in `m`.
|
|
68
|
+
* - `Alt`+letter (`ESC` followed by one character) has no introducer body, so
|
|
69
|
+
* the string-type rule does not fire.
|
|
70
|
+
*/
|
|
71
|
+
export declare function hasPasteOnlySequence(text: string): boolean;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-SEQUENCE removal for text that crosses a boundary where a terminal
|
|
3
|
+
* escape must never become content (paste-control-sequence-residue, fix A'-1).
|
|
4
|
+
*
|
|
5
|
+
* WHY A DELETE-THE-WHOLE-SEQUENCE RULE EXISTS. Deleting only the introducer
|
|
6
|
+
* byte turns an escape into visible text: `ESC [ 3 8 ; 2 m` loses its ESC and
|
|
7
|
+
* leaves `[38;2m`, which is a run of digits and semicolons somebody then reads
|
|
8
|
+
* on screen and sends to the model. The bytes were never content; the sequence
|
|
9
|
+
* has to go as a unit.
|
|
10
|
+
*
|
|
11
|
+
* WHY IT IS NOT `tools/terminal-output.ts`'s REGEXES MOVED HERE. Those four
|
|
12
|
+
* regexes answer "what does this look like" for a terminal row that is still
|
|
13
|
+
* being written, and they are fed text whose unfinished tail has ALREADY been
|
|
14
|
+
* cut off and parked in a carry by the caller (`sanitizeChunk`,
|
|
15
|
+
* `toDisplayRow`). Pasted text is one-shot: there is no next chunk, so a
|
|
16
|
+
* truncated sequence needs a rule of its own. Measured against the stream
|
|
17
|
+
* version of the same input, `ESC [ 3 8 ; 2 ; 1 8 ; 5 2 ; 8 6` followed by
|
|
18
|
+
* prose loses the first letter of the prose (`n`), and an unterminated OSC
|
|
19
|
+
* leaves its whole body behind.
|
|
20
|
+
*
|
|
21
|
+
* THE FOUR DECISIONS THIS MODULE ENCODES:
|
|
22
|
+
*
|
|
23
|
+
* 1. A sequence introduced by a real introducer byte (`0x1B`, `0x9B`, `0x9D`,
|
|
24
|
+
* ...) is removed as a unit; bare text that merely LOOKS like the inside of
|
|
25
|
+
* one (`[38;2;18;52;86m` typed or pasted without an ESC) is left exactly as
|
|
26
|
+
* it is, character for character.
|
|
27
|
+
* 2. A string-type sequence (OSC / DCS / SOS / PM / APC) runs to BEL or ST, and
|
|
28
|
+
* an unterminated one is removed UP TO THE END OF ITS LINE. It never crosses
|
|
29
|
+
* `\n` and never guesses at a terminator: a truncated sequence is the most
|
|
30
|
+
* common shape in a clipboard, and the only safe reading of it is "the rest
|
|
31
|
+
* of this line was never text".
|
|
32
|
+
* 3. A truncated CSI loses its introducer and its parameter bytes and NOTHING
|
|
33
|
+
* ELSE. `ESC [ 3 1 m` split mid-parameter is parameters, not prose, but the
|
|
34
|
+
* byte after them is the user's own character and must survive.
|
|
35
|
+
* 4. A C1 CSI (`0x9B`) with no parameter byte at all is treated as the stray
|
|
36
|
+
* control byte it is most likely to be rather than as a sequence, because a
|
|
37
|
+
* lone `0x9B` followed by one letter is otherwise indistinguishable from a
|
|
38
|
+
* non-UTF-8 file's stray byte followed by content -- and consuming that
|
|
39
|
+
* letter would be the very data loss this module exists to prevent.
|
|
40
|
+
*
|
|
41
|
+
* Pure, ASCII-only, React-free, Ink-free, and TOTAL: it never throws and it
|
|
42
|
+
* never reorders or mangles the bytes it keeps. No new dependency.
|
|
43
|
+
*/
|
|
44
|
+
/** ESC -- the introducer every terminal shares. */
|
|
45
|
+
const ESC = 0x1b;
|
|
46
|
+
/** LF. No rule in this module may consume it (decision 2). */
|
|
47
|
+
const LF = 0x0a;
|
|
48
|
+
/** BEL terminates an OSC. */
|
|
49
|
+
const BEL = 0x07;
|
|
50
|
+
/** `[` after ESC is CSI, and `\` after ESC is the two-byte ST. */
|
|
51
|
+
const BRACKET = 0x5b;
|
|
52
|
+
const BACKSLASH = 0x5c;
|
|
53
|
+
/** The ASCII second byte of each string-type sequence (`ESC ]`, `ESC P`, ...). */
|
|
54
|
+
const ESC_DCS = 0x50;
|
|
55
|
+
const ESC_SOS = 0x58;
|
|
56
|
+
const ESC_OSC = 0x5d;
|
|
57
|
+
const ESC_PM = 0x5e;
|
|
58
|
+
const ESC_APC = 0x5f;
|
|
59
|
+
/** C1 introducers, as written by a source that is not UTF-8 clean. */
|
|
60
|
+
const C1_CSI = 0x9b;
|
|
61
|
+
const C1_ST = 0x9c;
|
|
62
|
+
const C1_OSC = 0x9d;
|
|
63
|
+
const C1_DCS = 0x90;
|
|
64
|
+
const C1_SOS = 0x98;
|
|
65
|
+
const C1_PM = 0x9e;
|
|
66
|
+
const C1_APC = 0x9f;
|
|
67
|
+
/** Every byte that can START a sequence this module removes. */
|
|
68
|
+
const ANY_INTRODUCER = /[\u001b\u0090\u0098\u009b\u009d\u009e\u009f]/;
|
|
69
|
+
/**
|
|
70
|
+
* `CSI ... m` (SGR), in both encodings: `ESC [ digits ; m` and the single-byte
|
|
71
|
+
* C1 form `0x9B digits ; m`. NO KEY PRESS PRODUCES ONE, which is what makes it
|
|
72
|
+
* usable as the discriminator for an unframed paste in `stdin-filter.ts`.
|
|
73
|
+
*/
|
|
74
|
+
const SGR_SEQUENCE = /\u001b\[[0-9;]*m|\u009b[0-9;]*m/;
|
|
75
|
+
/**
|
|
76
|
+
* A string-type introducer followed by at least one body byte. An `Alt`+key
|
|
77
|
+
* press is exactly two bytes, so "introducer plus a body" is already outside
|
|
78
|
+
* what a keyboard can emit.
|
|
79
|
+
*/
|
|
80
|
+
const STRING_SEQUENCE = /(?:\u001b[\u0050\u0058\u005d\u005e\u005f]|[\u0090\u0098\u009d\u009e\u009f])[\s\S]/;
|
|
81
|
+
/** CSI final byte: `@` through `~`. */
|
|
82
|
+
function isFinalByte(code) {
|
|
83
|
+
return code >= 0x40 && code <= 0x7e;
|
|
84
|
+
}
|
|
85
|
+
/** CSI parameter byte: `0` through `?`. */
|
|
86
|
+
function isParamByte(code) {
|
|
87
|
+
return code >= 0x30 && code <= 0x3f;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* CSI intermediate byte: `!` through `/`.
|
|
91
|
+
*
|
|
92
|
+
* SPACE (`0x20`) IS DELIBERATELY EXCLUDED (decision 3). The standard allows it,
|
|
93
|
+
* but a pasted sequence cut mid-parameter is followed by ` ` and then prose,
|
|
94
|
+
* and the first letter of that prose is a legal final byte -- so honouring the
|
|
95
|
+
* space would silently eat it. `CSI Ps SP q` (cursor style) is the only shape
|
|
96
|
+
* that gets worse, and no child process writes that to its stdout.
|
|
97
|
+
*/
|
|
98
|
+
function isIntermediateByte(code) {
|
|
99
|
+
return code >= 0x21 && code <= 0x2f;
|
|
100
|
+
}
|
|
101
|
+
/** True for the C1 introducers that open a string, not a CSI. */
|
|
102
|
+
function isC1StringIntroducer(code) {
|
|
103
|
+
return code === C1_OSC || code === C1_DCS || code === C1_SOS || code === C1_PM || code === C1_APC;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* End of the string-type sequence whose body starts at `from`.
|
|
107
|
+
*
|
|
108
|
+
* Stops AT the line break rather than past it, and reports "consumed to here"
|
|
109
|
+
* for a body that never terminates (decision 2).
|
|
110
|
+
*/
|
|
111
|
+
function stringEnd(text, from) {
|
|
112
|
+
let i = from;
|
|
113
|
+
while (i < text.length) {
|
|
114
|
+
const code = text.charCodeAt(i);
|
|
115
|
+
if (code === BEL || code === C1_ST)
|
|
116
|
+
return i + 1;
|
|
117
|
+
if (code === LF)
|
|
118
|
+
return i;
|
|
119
|
+
if (code === ESC && text.charCodeAt(i + 1) === BACKSLASH)
|
|
120
|
+
return i + 2;
|
|
121
|
+
i += 1;
|
|
122
|
+
}
|
|
123
|
+
return i;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* End of the CSI whose parameter run starts at `from`.
|
|
127
|
+
*
|
|
128
|
+
* `requireParams` implements decision 4: with no parameter byte at all, a C1
|
|
129
|
+
* CSI is only the introducer byte, and its "final byte" is content.
|
|
130
|
+
*/
|
|
131
|
+
function csiEnd(text, from, requireParams) {
|
|
132
|
+
let i = from;
|
|
133
|
+
let sawParams = false;
|
|
134
|
+
while (i < text.length && isParamByte(text.charCodeAt(i))) {
|
|
135
|
+
sawParams = true;
|
|
136
|
+
i += 1;
|
|
137
|
+
}
|
|
138
|
+
if (requireParams && !sawParams)
|
|
139
|
+
return from;
|
|
140
|
+
while (i < text.length && isIntermediateByte(text.charCodeAt(i)))
|
|
141
|
+
i += 1;
|
|
142
|
+
if (i < text.length && isFinalByte(text.charCodeAt(i)))
|
|
143
|
+
return i + 1;
|
|
144
|
+
return i;
|
|
145
|
+
}
|
|
146
|
+
/** End of the sequence starting with ESC at `index` (decision 1 / 3). */
|
|
147
|
+
function escapeEnd(text, index) {
|
|
148
|
+
if (index + 1 >= text.length)
|
|
149
|
+
return index + 1;
|
|
150
|
+
const next = text.charCodeAt(index + 1);
|
|
151
|
+
if (next === BRACKET)
|
|
152
|
+
return csiEnd(text, index + 2, false);
|
|
153
|
+
if (next === ESC_OSC || next === ESC_DCS || next === ESC_SOS || next === ESC_PM
|
|
154
|
+
|| next === ESC_APC) {
|
|
155
|
+
return stringEnd(text, index + 2);
|
|
156
|
+
}
|
|
157
|
+
if (next === LF)
|
|
158
|
+
return index + 1;
|
|
159
|
+
return index + 2;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* End of the sequence starting at `index`, or `-1` when no sequence starts
|
|
163
|
+
* there.
|
|
164
|
+
*/
|
|
165
|
+
function sequenceEnd(text, index) {
|
|
166
|
+
const code = text.charCodeAt(index);
|
|
167
|
+
if (code === ESC)
|
|
168
|
+
return escapeEnd(text, index);
|
|
169
|
+
if (code === C1_CSI)
|
|
170
|
+
return csiEnd(text, index + 1, true);
|
|
171
|
+
if (isC1StringIntroducer(code)) {
|
|
172
|
+
return index + 1 >= text.length ? index + 1 : stringEnd(text, index + 1);
|
|
173
|
+
}
|
|
174
|
+
return -1;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Remove every escape/control sequence, keeping all other bytes in order.
|
|
178
|
+
*
|
|
179
|
+
* KEEPS `\t`, `\n` AND `\r`: they are line structure, and `\t` inside pasted
|
|
180
|
+
* indentation is content the model is supposed to receive. Stray C0/C1 bytes
|
|
181
|
+
* (and DEL) are NOT this function's job -- they are removed by the caller that
|
|
182
|
+
* wants them gone, so one rule has one owner.
|
|
183
|
+
*/
|
|
184
|
+
export function stripControlSequences(text) {
|
|
185
|
+
if (!ANY_INTRODUCER.test(text))
|
|
186
|
+
return text;
|
|
187
|
+
let out = '';
|
|
188
|
+
let kept = 0;
|
|
189
|
+
let i = 0;
|
|
190
|
+
while (i < text.length) {
|
|
191
|
+
const end = sequenceEnd(text, i);
|
|
192
|
+
if (end === -1) {
|
|
193
|
+
i += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
out += text.slice(kept, i);
|
|
197
|
+
i = end;
|
|
198
|
+
kept = end;
|
|
199
|
+
}
|
|
200
|
+
return kept === 0 ? text : out + text.slice(kept);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* True when `text` carries a sequence a KEYBOARD cannot physically produce.
|
|
204
|
+
*
|
|
205
|
+
* Used at the keystroke branch of the stdin filter: a single-line paste that
|
|
206
|
+
* carries ESC fails `classifyChunk`'s two rules (no line break, and
|
|
207
|
+
* `HAS_CONTROL_EXCEPT_TAB`) and would otherwise reach Ink as raw bytes --
|
|
208
|
+
* either typing `[38;2;18;52;86m` with a live ESC into the draft, or throwing
|
|
209
|
+
* an uncaught `TypeError` inside Ink's key parser.
|
|
210
|
+
*
|
|
211
|
+
* THREE THINGS MUST NOT MATCH, and the shapes below are why:
|
|
212
|
+
* - SGR mouse reports (`ESC [ < 0 ; 1 ; 2 m`) are stripped by `splitMouseEvents`
|
|
213
|
+
* BEFORE this test, and `<` is not a parameter byte, so the SGR rule cannot
|
|
214
|
+
* see them anyway.
|
|
215
|
+
* - Arrow / function / Home / End keys (`ESC [ A`, `ESC [ 1 ; 5 D`, `ESC [ 3 ~`,
|
|
216
|
+
* `ESC [ Z`) never end in `m`.
|
|
217
|
+
* - `Alt`+letter (`ESC` followed by one character) has no introducer body, so
|
|
218
|
+
* the string-type rule does not fire.
|
|
219
|
+
*/
|
|
220
|
+
export function hasPasteOnlySequence(text) {
|
|
221
|
+
return SGR_SEQUENCE.test(text) || STRING_SEQUENCE.test(text);
|
|
222
|
+
}
|
|
@@ -56,16 +56,28 @@ export declare function classifyChunk(text: string, minBurstChars?: number): 'pa
|
|
|
56
56
|
* newline in pasted text, and a lone LF is dropped by Ink's key parser
|
|
57
57
|
* entirely, so both have to be normalised here or a pasted line break either
|
|
58
58
|
* submits the message or silently vanishes.
|
|
59
|
-
* 2. Every
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
59
|
+
* 2. Every escape SEQUENCE is removed AS A SEQUENCE (`stripControlSequences`).
|
|
60
|
+
* Deleting only the introducer byte is what produced the reported defect:
|
|
61
|
+
* `ESC [ 3 8 ; 2 ; 1 8 ; 5 2 ; 8 6 m` minus its ESC is `[38;2;18;52;86m`,
|
|
62
|
+
* a run of digits Ink then writes to the terminal as ordinary text -- and it
|
|
63
|
+
* reached the model, the prompt history and the session file too, because
|
|
64
|
+
* this function is the only cleaning the payload ever gets.
|
|
65
|
+
* 3. Every remaining C0 byte except `\n` and `\t` is removed. Ink writes
|
|
66
|
+
* `<Text>` children to the terminal without escaping anything, so a
|
|
67
|
+
* surviving `\r` overwrites the row it is drawn on and a surviving `ESC`
|
|
68
|
+
* repaints the frame colours. Sequences are gone by this point, so what is
|
|
69
|
+
* left for this step is a stray byte, never half of one.
|
|
70
|
+
* 4. DEL and every C1 byte (U+0080..U+009F) go too, and that is NOT padding:
|
|
71
|
+
* U+009B is the single-byte CSI and U+0085 is NEL, and a terminal that
|
|
72
|
+
* decodes them acts on them exactly as it acts on `ESC [`. A file pasted out
|
|
73
|
+
* of a non-UTF-8 editor walks straight through a "strip C0" rule.
|
|
74
|
+
* 5. TAB is KEPT. Indentation is content, and it is what the model receives.
|
|
75
|
+
* Expanding it is the RENDERERS' job (`ui/display-tab.ts`), so the model
|
|
76
|
+
* keeps the original indentation while the screen, the composer and the
|
|
77
|
+
* terminal all agree on what column a TAB lands on.
|
|
67
78
|
*
|
|
68
79
|
* Applied to PAYLOAD ONLY, never to keystrokes, so `Ctrl+A` and Escape keep
|
|
69
|
-
* working. Non-ASCII text (CJK, emoji) is untouched.
|
|
80
|
+
* working. Non-ASCII text (CJK, emoji) is untouched. Idempotent, so the
|
|
81
|
+
* second pass in `ui/paste-frames.ts` stays a byte-identical no-op.
|
|
70
82
|
*/
|
|
71
83
|
export declare function sanitisePaste(raw: string): string;
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* `DECSET 2004`. Both are about what a keyboard can physically emit.
|
|
17
17
|
*/
|
|
18
18
|
import { PASTE_MIN_BURST_CHARS } from './limits.js';
|
|
19
|
+
import { stripControlSequences } from './control-sequences.js';
|
|
19
20
|
/** `DECSET 2004` wraps a paste in these two markers. */
|
|
20
21
|
export const PASTE_BEGIN_MARK = '\x1b[200~';
|
|
21
22
|
export const PASTE_END_MARK = '\x1b[201~';
|
|
@@ -76,20 +77,31 @@ export function classifyChunk(text, minBurstChars = PASTE_MIN_BURST_CHARS) {
|
|
|
76
77
|
* newline in pasted text, and a lone LF is dropped by Ink's key parser
|
|
77
78
|
* entirely, so both have to be normalised here or a pasted line break either
|
|
78
79
|
* submits the message or silently vanishes.
|
|
79
|
-
* 2. Every
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
80
|
+
* 2. Every escape SEQUENCE is removed AS A SEQUENCE (`stripControlSequences`).
|
|
81
|
+
* Deleting only the introducer byte is what produced the reported defect:
|
|
82
|
+
* `ESC [ 3 8 ; 2 ; 1 8 ; 5 2 ; 8 6 m` minus its ESC is `[38;2;18;52;86m`,
|
|
83
|
+
* a run of digits Ink then writes to the terminal as ordinary text -- and it
|
|
84
|
+
* reached the model, the prompt history and the session file too, because
|
|
85
|
+
* this function is the only cleaning the payload ever gets.
|
|
86
|
+
* 3. Every remaining C0 byte except `\n` and `\t` is removed. Ink writes
|
|
87
|
+
* `<Text>` children to the terminal without escaping anything, so a
|
|
88
|
+
* surviving `\r` overwrites the row it is drawn on and a surviving `ESC`
|
|
89
|
+
* repaints the frame colours. Sequences are gone by this point, so what is
|
|
90
|
+
* left for this step is a stray byte, never half of one.
|
|
91
|
+
* 4. DEL and every C1 byte (U+0080..U+009F) go too, and that is NOT padding:
|
|
92
|
+
* U+009B is the single-byte CSI and U+0085 is NEL, and a terminal that
|
|
93
|
+
* decodes them acts on them exactly as it acts on `ESC [`. A file pasted out
|
|
94
|
+
* of a non-UTF-8 editor walks straight through a "strip C0" rule.
|
|
95
|
+
* 5. TAB is KEPT. Indentation is content, and it is what the model receives.
|
|
96
|
+
* Expanding it is the RENDERERS' job (`ui/display-tab.ts`), so the model
|
|
97
|
+
* keeps the original indentation while the screen, the composer and the
|
|
98
|
+
* terminal all agree on what column a TAB lands on.
|
|
87
99
|
*
|
|
88
100
|
* Applied to PAYLOAD ONLY, never to keystrokes, so `Ctrl+A` and Escape keep
|
|
89
|
-
* working. Non-ASCII text (CJK, emoji) is untouched.
|
|
101
|
+
* working. Non-ASCII text (CJK, emoji) is untouched. Idempotent, so the
|
|
102
|
+
* second pass in `ui/paste-frames.ts` stays a byte-identical no-op.
|
|
90
103
|
*/
|
|
91
104
|
export function sanitisePaste(raw) {
|
|
92
|
-
return raw
|
|
93
|
-
.replace(/\r\n?/g, '\n')
|
|
105
|
+
return stripControlSequences(raw.replace(/\r\n?/g, '\n'))
|
|
94
106
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
|
|
95
107
|
}
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { PassThrough } from 'node:stream';
|
|
35
35
|
import { MAX_PENDING_MOUSE_CHARS, splitMouseEvents, isMousePrefix, } from './mouse-events.js';
|
|
36
|
-
import { ENTER_NEWLINE_FRAME, PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
|
|
36
|
+
import { ENTER_NEWLINE_FRAME, INDEX_KEY_FRAME, PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
|
|
37
37
|
import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
|
|
38
|
+
import { hasPasteOnlySequence } from './control-sequences.js';
|
|
38
39
|
import { splitEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
|
|
39
40
|
import { splitCsiUKeys, trailingCsiUPrefixLength, translateCsiUKey } from './csiu-keys.js';
|
|
40
41
|
import { splitWin32KeySequences, trailingWin32PrefixLength, translateWin32Key, } from './win32-input-mode.js';
|
|
@@ -206,8 +207,8 @@ export function createStdinFilter(real, features, bridge, onWarn) {
|
|
|
206
207
|
// The scan (section 5.1, and the ORDER below is the specification)
|
|
207
208
|
// -----------------------------------------------------------------------
|
|
208
209
|
/**
|
|
209
|
-
* Text that reached the pass-through:
|
|
210
|
-
* stripping, then Ink.
|
|
210
|
+
* Text that reached the pass-through: a sequence a KEYBOARD cannot produce
|
|
211
|
+
* first (R-2), then Tier 2 classification, then mouse stripping, then Ink.
|
|
211
212
|
*
|
|
212
213
|
* MOUSE PARSING IS SUSPENDED FOR THE DURATION OF ANY PASTE BODY (I-3), burst
|
|
213
214
|
* and bracketed alike. The Tier 2 population is precisely the terminals with
|
|
@@ -225,6 +226,23 @@ export function createStdinFilter(real, features, bridge, onWarn) {
|
|
|
225
226
|
burstTimer = arm(flushBurst, PASTE_BURST_MS);
|
|
226
227
|
return;
|
|
227
228
|
}
|
|
229
|
+
// A SEQUENCE A KEYBOARD CANNOT PRODUCE IS A PASTE, FRAMED OR NOT (R-2).
|
|
230
|
+
//
|
|
231
|
+
// WHY THE CLASSIFIER BELOW IS NOT ENOUGH. `classifyChunk` needs either a
|
|
232
|
+
// line break or a chunk with no control byte but TAB, and a SINGLE-LINE
|
|
233
|
+
// paste carrying ESC satisfies neither -- so it travels the keystroke path
|
|
234
|
+
// with `sanitisePaste` never called. Measured on the real component that
|
|
235
|
+
// cost either a draft holding `[38;2;18;52;86m` ALONGSIDE A LIVE ESC (which
|
|
236
|
+
// then reached the model, the history and the session file) or an uncaught
|
|
237
|
+
// `TypeError` in Ink's parser, because `parse-keypress.js` maps `ESC [ 3 2 m`
|
|
238
|
+
// to a ctrl key whose `name` is undefined. Framing it here routes it through
|
|
239
|
+
// the ONE cleaning path every other paste already takes.
|
|
240
|
+
if (features.paste && hasPasteOnlySequence(text)) {
|
|
241
|
+
paste = { kind: 'burst', body: text, bytes: byteLength(text) };
|
|
242
|
+
burstTimer = clearTimer(burstTimer);
|
|
243
|
+
burstTimer = arm(flushBurst, PASTE_BURST_MS);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
228
246
|
if (features.paste && classifyChunk(text) === 'paste') {
|
|
229
247
|
paste = { kind: 'burst', body: text, bytes: byteLength(text) };
|
|
230
248
|
burstTimer = clearTimer(burstTimer);
|
|
@@ -300,14 +318,47 @@ export function createStdinFilter(real, features, bridge, onWarn) {
|
|
|
300
318
|
}
|
|
301
319
|
}
|
|
302
320
|
};
|
|
321
|
+
/**
|
|
322
|
+
* win32-input-mode records that arrived INSIDE a paste frame (R-4).
|
|
323
|
+
*
|
|
324
|
+
* DATA FIDELITY, NOT POLISH. Every character of a `?9001h` session arrives as
|
|
325
|
+
* `ESC [ vk ; sc ; char ; down ; mods ; rep _`, and once the frame opens
|
|
326
|
+
* nothing translated them: `sanitisePaste` then removed each record's ESC and
|
|
327
|
+
* left 15 visible characters of digits per key. Translating them is also what
|
|
328
|
+
* keeps the fix honest in the OTHER direction -- a whole-sequence stripper
|
|
329
|
+
* deletes such a record outright, so "clean" would have meant the user's
|
|
330
|
+
* characters silently vanishing. A record carrying a key FRAME (modified
|
|
331
|
+
* Enter, Ctrl+I) becomes the byte that frame stands for, because a frame
|
|
332
|
+
* inside a body is content and never a key.
|
|
333
|
+
*/
|
|
334
|
+
const translateInPaste = (text) => {
|
|
335
|
+
if (!enhancedKeys || !text.includes('\x1b['))
|
|
336
|
+
return text;
|
|
337
|
+
let out = '';
|
|
338
|
+
for (const segment of splitWin32KeySequences(text)) {
|
|
339
|
+
if (segment.kind === 'text') {
|
|
340
|
+
out += segment.text;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const legacy = translateWin32Key(segment.record);
|
|
344
|
+
if (legacy === ENTER_NEWLINE_FRAME)
|
|
345
|
+
out += '\n';
|
|
346
|
+
else if (legacy === INDEX_KEY_FRAME)
|
|
347
|
+
out += '\t';
|
|
348
|
+
else
|
|
349
|
+
out += legacy;
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
};
|
|
303
353
|
const appendBracketed = (text) => {
|
|
304
354
|
if (paste.kind !== 'bracketed' || text.length === 0 || discardBracketed)
|
|
305
355
|
return;
|
|
306
|
-
const
|
|
356
|
+
const body = translateInPaste(text);
|
|
357
|
+
const bytes = paste.bytes + byteLength(body);
|
|
307
358
|
const overflow = paste.overflow || bytes > PASTE_MAX_BYTES;
|
|
308
359
|
paste = {
|
|
309
360
|
kind: 'bracketed',
|
|
310
|
-
body: overflow ? paste.body : paste.body +
|
|
361
|
+
body: overflow ? paste.body : paste.body + body,
|
|
311
362
|
bytes,
|
|
312
363
|
overflow,
|
|
313
364
|
};
|
|
@@ -65,6 +65,14 @@ export interface SanitizeResult {
|
|
|
65
65
|
/**
|
|
66
66
|
* Remove every escape sequence and control byte except `\t` `\n` `\r`.
|
|
67
67
|
*
|
|
68
|
+
* SEQUENCE SYNTAX HAS ONE OWNER (`input/control-sequences.ts`); this function
|
|
69
|
+
* adds only the control-byte sweep a DISPLAY row wants. Both call sites in this
|
|
70
|
+
* module already cut an unfinished escape off into the carry before calling it
|
|
71
|
+
* (`partialEscapeStart`), so the shared one-shot rule for a truncated sequence
|
|
72
|
+
* is exactly what they need -- and it fixes AC-24's failure in place: the old
|
|
73
|
+
* two-byte rule consumed the `\x1b[` of an unfinished CSI and left the
|
|
74
|
+
* PARAMETERS behind, so `\x1b[31m` split across chunks rendered as `red 3`.
|
|
75
|
+
*
|
|
68
76
|
* Exported for the tests, and used by `toDisplayRow` as the second line of
|
|
69
77
|
* defence over a carry that ends mid-escape.
|
|
70
78
|
*/
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* interpreting cursor motion would mean owning a screen buffer. Neither is a
|
|
23
23
|
* presentation round's job.
|
|
24
24
|
*/
|
|
25
|
+
import { stripControlSequences } from '../input/control-sequences.js';
|
|
25
26
|
/** Columns a live row may occupy before it is clipped. */
|
|
26
27
|
export const LIVE_ROW_MAX_CHARS = 200;
|
|
27
28
|
/** Tab stop width, matching every terminal's default. */
|
|
@@ -57,22 +58,24 @@ export const TRUNCATION_MARK = '...';
|
|
|
57
58
|
* and holding it forever would pin the carry.
|
|
58
59
|
*/
|
|
59
60
|
const MAX_PARTIAL_ESCAPE = 64;
|
|
60
|
-
/** CSI: `\x1b[` params intermediates final. */
|
|
61
|
-
const CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
62
|
-
/** OSC: `\x1b]` ... terminated by BEL or ST. */
|
|
63
|
-
const OSC = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
|
|
64
|
-
/** The two-byte forms: `\x1b` followed by one character. */
|
|
65
|
-
const ESC_PAIR = /\x1b[\s\S]/g;
|
|
66
61
|
/** C1, plus every C0 control except `\t` `\n` `\r`. */
|
|
67
62
|
const CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
|
|
68
63
|
/**
|
|
69
64
|
* Remove every escape sequence and control byte except `\t` `\n` `\r`.
|
|
70
65
|
*
|
|
66
|
+
* SEQUENCE SYNTAX HAS ONE OWNER (`input/control-sequences.ts`); this function
|
|
67
|
+
* adds only the control-byte sweep a DISPLAY row wants. Both call sites in this
|
|
68
|
+
* module already cut an unfinished escape off into the carry before calling it
|
|
69
|
+
* (`partialEscapeStart`), so the shared one-shot rule for a truncated sequence
|
|
70
|
+
* is exactly what they need -- and it fixes AC-24's failure in place: the old
|
|
71
|
+
* two-byte rule consumed the `\x1b[` of an unfinished CSI and left the
|
|
72
|
+
* PARAMETERS behind, so `\x1b[31m` split across chunks rendered as `red 3`.
|
|
73
|
+
*
|
|
71
74
|
* Exported for the tests, and used by `toDisplayRow` as the second line of
|
|
72
75
|
* defence over a carry that ends mid-escape.
|
|
73
76
|
*/
|
|
74
77
|
export function stripAnsi(text) {
|
|
75
|
-
return text
|
|
78
|
+
return stripControlSequences(text).replace(CONTROLS, '');
|
|
76
79
|
}
|
|
77
80
|
/**
|
|
78
81
|
* Index at which a trailing INCOMPLETE escape begins, or `-1`.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TAB expansion that agrees with the TERMINAL, not with UTF-16 (fix A'-2 of
|
|
3
|
+
* paste-control-sequence-residue).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS A MODULE OF ITS OWN. A TAB has no width; it advances the pen to
|
|
6
|
+
* the next tab stop, so what it occupies depends on where the line currently
|
|
7
|
+
* is. That makes "one TAB" a property of a CURSOR, and the same character was
|
|
8
|
+
* being read three different ways in this package:
|
|
9
|
+
*
|
|
10
|
+
* - `ui/editor-navigation.ts` painted it at FOUR-column stops in the composer,
|
|
11
|
+
* - `entries/UserEntry.tsx` handed the raw byte to `<Text>`, so the terminal
|
|
12
|
+
* painted it at EIGHT-column stops in the transcript,
|
|
13
|
+
* - `tools/terminal-output.ts` expanded it by UTF-16 code-unit count, so a
|
|
14
|
+
* pasted `中文<TAB>X` table came out two columns short.
|
|
15
|
+
*
|
|
16
|
+
* A user who pastes a TAB-indented block or an Excel/Word table therefore saw
|
|
17
|
+
* the draft and the sent message disagree about their own text. One function,
|
|
18
|
+
* used by the composer AND by every renderer of stored text, is what makes the
|
|
19
|
+
* "before send" and "after send" rows character-identical.
|
|
20
|
+
*
|
|
21
|
+
* `input/` is the wrong home for it: `cli/input` may depend on `node:stream`
|
|
22
|
+
* and nothing else (`.claude-index/index.md`), and column arithmetic needs the
|
|
23
|
+
* width table. `ui/` already owns that (`selection/selection.ts` uses
|
|
24
|
+
* `string-width` for exactly this reason).
|
|
25
|
+
*
|
|
26
|
+
* Pure, React-free, Ink-free.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Tab stop width, matching every terminal's default.
|
|
30
|
+
*
|
|
31
|
+
* A COPY OF `tools/terminal-output.ts`'s number, on purpose and in the same
|
|
32
|
+
* shape `composer-limits.ts` copies `budget.ts::HINT_MIN_ROWS`: importing the
|
|
33
|
+
* tools module here would point an arrow from `ui/` into `tools/` for one
|
|
34
|
+
* integer, and the two are asserted equal by a test instead. (`tools/` keeps
|
|
35
|
+
* its own expander because its rows are clipped to 200 columns before anyone
|
|
36
|
+
* looks at them, so a code-unit approximation there cannot reach a user.)
|
|
37
|
+
*/
|
|
38
|
+
export declare const TAB_WIDTH = 8;
|
|
39
|
+
/**
|
|
40
|
+
* Expand every TAB in `text` to spaces up to the next `TAB_WIDTH` stop, given
|
|
41
|
+
* the display column the first character will be painted at.
|
|
42
|
+
*
|
|
43
|
+
* `fromColumn` is a DISPLAY column, not an offset: a caller that has already
|
|
44
|
+
* painted `中文` must pass 4. Lines are independent -- a `\n` resets the column
|
|
45
|
+
* to zero, because the terminal's pen returns to column 0.
|
|
46
|
+
*
|
|
47
|
+
* Non-ASCII text is measured with `string-width`, which is the whole point: a
|
|
48
|
+
* CJK glyph occupies two cells, and counting it as one space is what left
|
|
49
|
+
* CJK-plus-TAB tables misaligned.
|
|
50
|
+
*/
|
|
51
|
+
export declare function expandTabs(text: string, fromColumn?: number): string;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TAB expansion that agrees with the TERMINAL, not with UTF-16 (fix A'-2 of
|
|
3
|
+
* paste-control-sequence-residue).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS A MODULE OF ITS OWN. A TAB has no width; it advances the pen to
|
|
6
|
+
* the next tab stop, so what it occupies depends on where the line currently
|
|
7
|
+
* is. That makes "one TAB" a property of a CURSOR, and the same character was
|
|
8
|
+
* being read three different ways in this package:
|
|
9
|
+
*
|
|
10
|
+
* - `ui/editor-navigation.ts` painted it at FOUR-column stops in the composer,
|
|
11
|
+
* - `entries/UserEntry.tsx` handed the raw byte to `<Text>`, so the terminal
|
|
12
|
+
* painted it at EIGHT-column stops in the transcript,
|
|
13
|
+
* - `tools/terminal-output.ts` expanded it by UTF-16 code-unit count, so a
|
|
14
|
+
* pasted `中文<TAB>X` table came out two columns short.
|
|
15
|
+
*
|
|
16
|
+
* A user who pastes a TAB-indented block or an Excel/Word table therefore saw
|
|
17
|
+
* the draft and the sent message disagree about their own text. One function,
|
|
18
|
+
* used by the composer AND by every renderer of stored text, is what makes the
|
|
19
|
+
* "before send" and "after send" rows character-identical.
|
|
20
|
+
*
|
|
21
|
+
* `input/` is the wrong home for it: `cli/input` may depend on `node:stream`
|
|
22
|
+
* and nothing else (`.claude-index/index.md`), and column arithmetic needs the
|
|
23
|
+
* width table. `ui/` already owns that (`selection/selection.ts` uses
|
|
24
|
+
* `string-width` for exactly this reason).
|
|
25
|
+
*
|
|
26
|
+
* Pure, React-free, Ink-free.
|
|
27
|
+
*/
|
|
28
|
+
import stringWidth from 'string-width';
|
|
29
|
+
/**
|
|
30
|
+
* Tab stop width, matching every terminal's default.
|
|
31
|
+
*
|
|
32
|
+
* A COPY OF `tools/terminal-output.ts`'s number, on purpose and in the same
|
|
33
|
+
* shape `composer-limits.ts` copies `budget.ts::HINT_MIN_ROWS`: importing the
|
|
34
|
+
* tools module here would point an arrow from `ui/` into `tools/` for one
|
|
35
|
+
* integer, and the two are asserted equal by a test instead. (`tools/` keeps
|
|
36
|
+
* its own expander because its rows are clipped to 200 columns before anyone
|
|
37
|
+
* looks at them, so a code-unit approximation there cannot reach a user.)
|
|
38
|
+
*/
|
|
39
|
+
export const TAB_WIDTH = 8;
|
|
40
|
+
/**
|
|
41
|
+
* Expand every TAB in `text` to spaces up to the next `TAB_WIDTH` stop, given
|
|
42
|
+
* the display column the first character will be painted at.
|
|
43
|
+
*
|
|
44
|
+
* `fromColumn` is a DISPLAY column, not an offset: a caller that has already
|
|
45
|
+
* painted `中文` must pass 4. Lines are independent -- a `\n` resets the column
|
|
46
|
+
* to zero, because the terminal's pen returns to column 0.
|
|
47
|
+
*
|
|
48
|
+
* Non-ASCII text is measured with `string-width`, which is the whole point: a
|
|
49
|
+
* CJK glyph occupies two cells, and counting it as one space is what left
|
|
50
|
+
* CJK-plus-TAB tables misaligned.
|
|
51
|
+
*/
|
|
52
|
+
export function expandTabs(text, fromColumn = 0) {
|
|
53
|
+
if (text.indexOf('\t') === -1)
|
|
54
|
+
return text;
|
|
55
|
+
let out = '';
|
|
56
|
+
let column = Math.max(0, Math.floor(fromColumn));
|
|
57
|
+
let run = '';
|
|
58
|
+
for (const part of graphemesOf(text)) {
|
|
59
|
+
if (part === '\t') {
|
|
60
|
+
out += run;
|
|
61
|
+
run = '';
|
|
62
|
+
const width = TAB_WIDTH - (column % TAB_WIDTH);
|
|
63
|
+
out += ' '.repeat(width);
|
|
64
|
+
column += width;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
run += part;
|
|
68
|
+
if (part === '\n')
|
|
69
|
+
column = 0;
|
|
70
|
+
else
|
|
71
|
+
column += stringWidth(part);
|
|
72
|
+
}
|
|
73
|
+
return out + run;
|
|
74
|
+
}
|
|
75
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
76
|
+
/**
|
|
77
|
+
* Grapheme clusters rather than code units, so a combining mark or an emoji ZWJ
|
|
78
|
+
* sequence advances the column once, exactly as the terminal draws it.
|
|
79
|
+
*/
|
|
80
|
+
function graphemesOf(text) {
|
|
81
|
+
return Array.from(segmenter.segment(text), ({ segment }) => segment);
|
|
82
|
+
}
|
|
@@ -25,7 +25,17 @@ export interface EditorVisualMap {
|
|
|
25
25
|
readonly positions: VisualPosition[];
|
|
26
26
|
readonly cursor: VisualPosition;
|
|
27
27
|
}
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation.
|
|
30
|
+
*
|
|
31
|
+
* THE TAB STOP IS THE TERMINAL'S (`TAB_WIDTH`), NOT AN EDITOR-SIZED FOUR. It
|
|
32
|
+
* used to be four, while the transcript handed the raw `\t` to `<Text>` and
|
|
33
|
+
* the terminal painted it at eight: the same pasted table sat one column off
|
|
34
|
+
* from itself before and after sending. A tab that would cross the right edge
|
|
35
|
+
* is still clipped to the row (the caret cannot point past the last cell), so
|
|
36
|
+
* a tab STRADDLING the wrap is the one shape where the draft and the stored
|
|
37
|
+
* text can differ by the remainder of the advance.
|
|
38
|
+
*/
|
|
29
39
|
export declare function buildEditorVisualMap(input: {
|
|
30
40
|
buffer: string;
|
|
31
41
|
cols: number;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Shared UTF-16 grapheme boundaries and terminal-cell layout for the editor. */
|
|
2
2
|
import stringWidth from 'string-width';
|
|
3
|
+
import { TAB_WIDTH } from './display-tab.js';
|
|
3
4
|
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
4
5
|
/** Segment text without changing its UTF-16 offsets or stored whitespace. */
|
|
5
6
|
export function graphemes(text) {
|
|
@@ -31,7 +32,17 @@ export function graphemeWidth(text) {
|
|
|
31
32
|
return 1;
|
|
32
33
|
return stringWidth(text);
|
|
33
34
|
}
|
|
34
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation.
|
|
37
|
+
*
|
|
38
|
+
* THE TAB STOP IS THE TERMINAL'S (`TAB_WIDTH`), NOT AN EDITOR-SIZED FOUR. It
|
|
39
|
+
* used to be four, while the transcript handed the raw `\t` to `<Text>` and
|
|
40
|
+
* the terminal painted it at eight: the same pasted table sat one column off
|
|
41
|
+
* from itself before and after sending. A tab that would cross the right edge
|
|
42
|
+
* is still clipped to the row (the caret cannot point past the last cell), so
|
|
43
|
+
* a tab STRADDLING the wrap is the one shape where the draft and the stored
|
|
44
|
+
* text can differ by the remainder of the advance.
|
|
45
|
+
*/
|
|
35
46
|
export function buildEditorVisualMap(input) {
|
|
36
47
|
const cols = Math.max(1, Math.floor(input.cols));
|
|
37
48
|
const cursor = snapGrapheme(input.buffer, input.cursor);
|
|
@@ -50,12 +61,12 @@ export function buildEditorVisualMap(input) {
|
|
|
50
61
|
breakRow();
|
|
51
62
|
continue;
|
|
52
63
|
}
|
|
53
|
-
let width = part.text === '\t' ? Math.min(cols,
|
|
64
|
+
let width = part.text === '\t' ? Math.min(cols, TAB_WIDTH - column % TAB_WIDTH)
|
|
54
65
|
: graphemeWidth(part.text);
|
|
55
66
|
if (column > 0 && column + width > cols) {
|
|
56
67
|
breakRow();
|
|
57
68
|
if (part.text === '\t')
|
|
58
|
-
width = Math.min(cols,
|
|
69
|
+
width = Math.min(cols, TAB_WIDTH);
|
|
59
70
|
}
|
|
60
71
|
locate(part.index);
|
|
61
72
|
rows[rows.length - 1].push({ ...part,
|
|
@@ -13,10 +13,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
13
13
|
import React from 'react';
|
|
14
14
|
import { Text } from 'ink';
|
|
15
15
|
import { pickGlyphs } from '../glyphs.js';
|
|
16
|
+
import { expandTabs } from '../display-tab.js';
|
|
16
17
|
function QueuedEntryImpl({ text, theme, caps, }) {
|
|
17
18
|
const glyphs = pickGlyphs(caps);
|
|
18
19
|
const lines = text.split('\n');
|
|
19
|
-
|
|
20
|
+
// Same reason as `UserEntry`: a queued draft is a draft, so its TABs must
|
|
21
|
+
// occupy the columns the composer showed them at.
|
|
22
|
+
const first = expandTabs(lines[0] ?? '');
|
|
20
23
|
const extra = lines.length - 1;
|
|
21
24
|
const suffix = extra > 0 ? ` ${glyphs.ellipsis}+${extra} line${extra === 1 ? '' : 's'}` : '';
|
|
22
25
|
return (_jsx(Text, { wrap: "truncate", color: theme.muted, children: `Queue: ${first}${suffix}` }));
|
|
@@ -14,9 +14,16 @@ import React from 'react';
|
|
|
14
14
|
import { Box, Text } from 'ink';
|
|
15
15
|
import { pickGlyphs } from '../glyphs.js';
|
|
16
16
|
import { USER_ENTRY_MAX_ROWS } from '../composer-limits.js';
|
|
17
|
+
import { expandTabs } from '../display-tab.js';
|
|
17
18
|
function UserEntryImpl({ text, theme, caps, }) {
|
|
18
19
|
const glyphs = pickGlyphs(caps);
|
|
19
|
-
|
|
20
|
+
// TAB IS EXPANDED HERE, AT THE RENDER BOUNDARY, AND NOWHERE ABOVE IT.
|
|
21
|
+
// The payload keeps its `\t` (the model must receive the original
|
|
22
|
+
// indentation), so `<Text>` used to hand the raw byte to the terminal and
|
|
23
|
+
// the terminal painted an EIGHT-column stop while the composer painted a
|
|
24
|
+
// FOUR-column one. Splitting first and expanding per line keeps this row
|
|
25
|
+
// identical to the row the user saw in the draft.
|
|
26
|
+
const all = text.split('\n').map((line) => expandTabs(line));
|
|
20
27
|
// A submitted 5 000-line paste otherwise asks Yoga to lay out 5 000 nodes
|
|
21
28
|
// inside a measured entry -- the hazard `render-cache.ts:37` already records
|
|
22
29
|
// in the same words ("a multi-megabyte 'code block' is almost always a pasted
|
|
@@ -32,9 +32,11 @@
|
|
|
32
32
|
* `selectWindow` takes `offset` as an INPUT, would close a loop from the offset
|
|
33
33
|
* through the mounted set and the measured heights back to the offset.
|
|
34
34
|
*/
|
|
35
|
+
import stringWidth from 'string-width';
|
|
35
36
|
import { FAST_LIMITS } from '../../fast/limits.js';
|
|
36
37
|
import { COMPACTION_LIMITS } from '../../compaction/limits.js';
|
|
37
38
|
import { GUTTER_WIDTH } from './Gutter.js';
|
|
39
|
+
import { expandTabs } from '../display-tab.js';
|
|
38
40
|
import { USER_ENTRY_MAX_ROWS } from '../composer-limits.js';
|
|
39
41
|
export const VIRTUAL_LIMITS = {
|
|
40
42
|
/**
|
|
@@ -159,21 +161,52 @@ export function entryRevision(entry) {
|
|
|
159
161
|
export function heightKey(entry, cols, expanded, density, flags = '') {
|
|
160
162
|
return `${entry.id}|${entryRevision(entry)}|${cols}|${expanded ? 1 : 0}|${density}|${flags}`;
|
|
161
163
|
}
|
|
162
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Rows a string occupies at `usable` columns, wrapping included. At least 1.
|
|
166
|
+
*
|
|
167
|
+
* COUNTED IN DISPLAY COLUMNS, NOT UTF-16 CODE UNITS, and the difference is a
|
|
168
|
+
* correctness bug rather than a nicety: a CJK glyph is one code unit and two
|
|
169
|
+
* cells, and a TAB is one code unit and up to `TAB_WIDTH` cells. Counting code
|
|
170
|
+
* units therefore UNDER-estimates, which this file's own note names as the
|
|
171
|
+
* unsafe direction ("an over-estimate makes the scroll position slightly
|
|
172
|
+
* conservative and self-corrects ... an under-estimate can put the newest
|
|
173
|
+
* output off the bottom of a viewport the user believes is pinned"). It also
|
|
174
|
+
* had to change together with the renderers: a stored `\t` is now expanded at
|
|
175
|
+
* paint time, so measuring the unexpanded string would keep the estimate short
|
|
176
|
+
* while the painted row got taller.
|
|
177
|
+
*/
|
|
163
178
|
function wrappedRows(text, usable) {
|
|
164
179
|
const width = Math.max(1, usable);
|
|
165
180
|
let rows = 0;
|
|
166
|
-
let
|
|
167
|
-
for (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
181
|
+
let start = 0;
|
|
182
|
+
for (;;) {
|
|
183
|
+
const end = text.indexOf('\n', start);
|
|
184
|
+
const line = end === -1 ? text.slice(start) : text.slice(start, end);
|
|
185
|
+
rows += Math.max(1, Math.ceil(displayColumns(line) / width));
|
|
186
|
+
if (end === -1)
|
|
187
|
+
return rows;
|
|
188
|
+
start = end + 1;
|
|
175
189
|
}
|
|
176
|
-
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Printable ASCII with no TAB: one code unit is one cell, so the width table is
|
|
193
|
+
* not consulted at all.
|
|
194
|
+
*
|
|
195
|
+
* THE FAST PATH IS LOAD-BEARING, not a micro-optimisation. `stringWidth`
|
|
196
|
+
* strips ANSI and walks the East-Asian width table for every code point, and
|
|
197
|
+
* this function runs over EVERY unmeasured entry of a 10 000-entry document
|
|
198
|
+
* (`render-budget.test.tsx` mounts exactly that); measuring them all with the
|
|
199
|
+
* table turned that test into a timeout. Transcript text is overwhelmingly
|
|
200
|
+
* ASCII, so the branch is taken almost always.
|
|
201
|
+
*/
|
|
202
|
+
const PLAIN_ASCII = /^[\x20-\x7e]*$/;
|
|
203
|
+
/** Display columns of one logical line: TABs expanded, CJK counted twice. */
|
|
204
|
+
function displayColumns(line) {
|
|
205
|
+
if (line.indexOf('\t') === -1 && PLAIN_ASCII.test(line))
|
|
206
|
+
return line.length;
|
|
207
|
+
// `expandTabs` mirrors what `entries/*` paints; `stringWidth` is the width
|
|
208
|
+
// table the composer already wraps with, so the two agree on CJK and emoji.
|
|
209
|
+
return stringWidth(expandTabs(line));
|
|
177
210
|
}
|
|
178
211
|
/** Collapsed preview rows in `ToolCard`; duplicated as a NUMBER, not imported. */
|
|
179
212
|
const TOOL_COLLAPSED_LINES = 8;
|
|
@@ -82,6 +82,10 @@ export declare function hasPasteFrame(input: string): boolean;
|
|
|
82
82
|
* nothing on screen to explain it.
|
|
83
83
|
*
|
|
84
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.
|
|
85
|
+
* stray CR or an ANSI sequence is now cleaned rather than stored raw. The
|
|
86
|
+
* unframed branch strips escape SEQUENCES before the control sweep, because
|
|
87
|
+
* deleting only the introducer left `[0m` in the stored value -- and in a
|
|
88
|
+
* settings field that residue is invisible behind `maskDot` while the value
|
|
89
|
+
* handed to `registerSecret()` is silently wrong.
|
|
86
90
|
*/
|
|
87
91
|
export declare function stripPasteFrames(input: string): string;
|
package/dist/ui/paste-frames.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { PASTE_CLOSE, PASTE_OPEN } from '../input/limits.js';
|
|
9
9
|
import { sanitisePaste } from '../input/paste-parse.js';
|
|
10
|
+
import { stripControlSequences } from '../input/control-sequences.js';
|
|
10
11
|
/**
|
|
11
12
|
* Remove every C0 byte, DEL and C1 byte from text that arrived OUTSIDE a frame
|
|
12
13
|
* (I-14). NO CR -> LF conversion, and that omission is the point.
|
|
@@ -101,11 +102,15 @@ export function hasPasteFrame(input) {
|
|
|
101
102
|
* nothing on screen to explain it.
|
|
102
103
|
*
|
|
103
104
|
* 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
|
+
* stray CR or an ANSI sequence is now cleaned rather than stored raw. The
|
|
106
|
+
* unframed branch strips escape SEQUENCES before the control sweep, because
|
|
107
|
+
* deleting only the introducer left `[0m` in the stored value -- and in a
|
|
108
|
+
* settings field that residue is invisible behind `maskDot` while the value
|
|
109
|
+
* handed to `registerSecret()` is silently wrong.
|
|
105
110
|
*/
|
|
106
111
|
export function stripPasteFrames(input) {
|
|
107
112
|
if (!hasPasteFrame(input))
|
|
108
|
-
return sanitiseTyped(input);
|
|
113
|
+
return sanitiseTyped(stripControlSequences(input));
|
|
109
114
|
return splitPasteFrames(input)
|
|
110
115
|
.map((segment) => (segment.kind === 'paste' ? sanitiseTyped(segment.text) : segment.text))
|
|
111
116
|
.join('');
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { formatCost, formatDuration, formatTokens, promptTokensOf } from '../agent/usage.js';
|
|
2
2
|
import { formatRetryLine } from '../agent/retry-view.js';
|
|
3
|
+
import { expandTabs } from './display-tab.js';
|
|
3
4
|
const DEFAULT_MAX_ENTRIES = 200;
|
|
4
5
|
const CONTINUATION_INDENT = ' ';
|
|
5
6
|
const TOOL_INDENT = ' ';
|
|
6
7
|
const MAX_ARG_CHARS = 60;
|
|
7
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Prefix the first line with `marker`, indent the rest to line up under it.
|
|
10
|
+
*
|
|
11
|
+
* `expandTabs` RUNS BEFORE THE PREFIX IS ADDED, so a stored line lands on the
|
|
12
|
+
* same columns the TUI drew it at. Without it this replay -- the only record
|
|
13
|
+
* left once the alternate screen is torn down -- disagreed with the screen
|
|
14
|
+
* about every TAB, because the terminal painted an eight-column stop under text
|
|
15
|
+
* the composer had laid out at four.
|
|
16
|
+
*/
|
|
8
17
|
function block(marker, text) {
|
|
9
|
-
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
18
|
+
const lines = expandTabs(text.replace(/\r\n/g, '\n')).split('\n');
|
|
10
19
|
const head = `${marker} ${lines[0] ?? ''}`.trimEnd();
|
|
11
20
|
const rest = lines.slice(1).map((l) => `${CONTINUATION_INDENT}${l}`.trimEnd());
|
|
12
21
|
return [head, ...rest];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aragon-agent/cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.28",
|
|
4
4
|
"description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AragonAgent contributors",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"prepublishOnly": "npm run build"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@aragon-agent/core": "^0.2.
|
|
59
|
+
"@aragon-agent/core": "^0.2.40",
|
|
60
60
|
"chalk": "^5.6.2",
|
|
61
61
|
"cli-highlight": "^2.0.0",
|
|
62
62
|
"commander": "^12.1.0",
|