@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 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 as-is 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. `--no-paste` restores the pre-0.6.3 behaviour |
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, control bytes are stripped instead of overwriting the row they land on, 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. |
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 C0 byte except `\n` and `\t` is removed. Ink writes `<Text>` children
60
- * to the terminal without escaping anything, so a surviving `\r` overwrites
61
- * the row it is drawn on and a surviving `\x1b` repaints the frame's colours.
62
- * 3. DEL and every C1 byte (U+0080..U+009F) go too, and that is NOT padding:
63
- * U+009B is the single-byte CSI and U+0085 is NEL, and a terminal that decodes
64
- * them acts on them exactly as it acts on `\x1b[`. A file pasted out of a
65
- * non-UTF-8 editor walks straight through a "strip C0" rule.
66
- * 4. TAB is KEPT. Indentation is content, and it is what the model receives.
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 C0 byte except `\n` and `\t` is removed. Ink writes `<Text>` children
80
- * to the terminal without escaping anything, so a surviving `\r` overwrites
81
- * the row it is drawn on and a surviving `\x1b` repaints the frame's colours.
82
- * 3. DEL and every C1 byte (U+0080..U+009F) go too, and that is NOT padding:
83
- * U+009B is the single-byte CSI and U+0085 is NEL, and a terminal that decodes
84
- * them acts on them exactly as it acts on `\x1b[`. A file pasted out of a
85
- * non-UTF-8 editor walks straight through a "strip C0" rule.
86
- * 4. TAB is KEPT. Indentation is content, and it is what the model receives.
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: Tier 2 classification, then mouse
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 bytes = paste.bytes + byteLength(text);
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 + text,
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.replace(CSI, '').replace(OSC, '').replace(ESC_PAIR, '').replace(CONTROLS, '');
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
- /** Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation. */
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
- /** Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation. */
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, 4 - column % 4)
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, 4);
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
- const first = lines[0] ?? '';
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
- const all = text.split('\n');
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
- /** Rows a string occupies at `usable` columns, wrapping included. At least 1. */
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 lineLen = 0;
167
- for (let i = 0; i < text.length; i += 1) {
168
- if (text.charCodeAt(i) === 10) {
169
- rows += Math.max(1, Math.ceil(lineLen / width));
170
- lineLen = 0;
171
- }
172
- else {
173
- lineLen += 1;
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
- return rows + Math.max(1, Math.ceil(lineLen / width));
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;
@@ -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
- /** Prefix the first line with `marker`, indent the rest to line up under it. */
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.27",
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.39",
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",