@aragon-agent/cli 0.6.27 → 0.6.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +2 -2
  3. package/dist/agent/reducer.js +47 -2
  4. package/dist/input/control-sequences.d.ts +71 -0
  5. package/dist/input/control-sequences.js +222 -0
  6. package/dist/input/limits.d.ts +20 -0
  7. package/dist/input/limits.js +20 -0
  8. package/dist/input/paste-parse.d.ts +21 -9
  9. package/dist/input/paste-parse.js +23 -11
  10. package/dist/input/stdin-filter.js +123 -9
  11. package/dist/tools/terminal-output.d.ts +8 -0
  12. package/dist/tools/terminal-output.js +10 -7
  13. package/dist/ui/App.js +34 -6
  14. package/dist/ui/AutocompletePopup.js +1 -1
  15. package/dist/ui/Header.js +7 -2
  16. package/dist/ui/Markdown.js +30 -3
  17. package/dist/ui/PromptInput.js +41 -3
  18. package/dist/ui/Transcript.js +7 -0
  19. package/dist/ui/display-tab.d.ts +51 -0
  20. package/dist/ui/display-tab.js +82 -0
  21. package/dist/ui/editor-navigation.d.ts +11 -1
  22. package/dist/ui/editor-navigation.js +14 -3
  23. package/dist/ui/entries/QueuedEntry.js +4 -1
  24. package/dist/ui/entries/UserEntry.js +8 -1
  25. package/dist/ui/layout/Gutter.js +1 -1
  26. package/dist/ui/layout/OverlayFrame.d.ts +15 -0
  27. package/dist/ui/layout/OverlayFrame.js +18 -0
  28. package/dist/ui/layout/ScrollIndicator.js +10 -3
  29. package/dist/ui/layout/budget.d.ts +15 -0
  30. package/dist/ui/layout/budget.js +15 -0
  31. package/dist/ui/layout/header-hints.d.ts +9 -0
  32. package/dist/ui/layout/header-hints.js +14 -1
  33. package/dist/ui/layout/status-layout.js +63 -2
  34. package/dist/ui/layout/virtual-window.js +44 -11
  35. package/dist/ui/markdown-blocks.d.ts +26 -5
  36. package/dist/ui/markdown-blocks.js +110 -33
  37. package/dist/ui/paste-frames.d.ts +5 -1
  38. package/dist/ui/paste-frames.js +7 -2
  39. package/dist/ui/transcript-text.js +11 -2
  40. package/dist/ui/use-wheel-routing.d.ts +10 -7
  41. package/dist/ui/use-wheel-routing.js +11 -6
  42. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -77,6 +77,170 @@ were written.
77
77
 
78
78
  ### Fixed
79
79
 
80
+ - **A long answer lost two characters at every hard wrap point - silently, in
81
+ exactly the content a user copies out.** The transcript's content column had no
82
+ width of its own, so Yoga laid each line out at the width it computed from the
83
+ parent while `virtual-window` estimated heights against `cols - GUTTER_WIDTH`:
84
+ two authorities for one number, and the clip kept the difference. A 240-digit
85
+ run in a 100-column transcript rendered as 97 + 97 + 42 = 236 digits, dropping
86
+ the characters at column 97 and 194 - URLs, git hashes, base64, paths and code
87
+ lines broke where nothing looked broken, because a paragraph with spaces re-wraps
88
+ at word boundaries and hides it. The column now takes `flexBasis={0}
89
+ flexGrow={1}` and keeps `flexShrink={0}` (invariant I-2: without it
90
+ `measureElement` can squeeze the column to the viewport and scrolling dies with
91
+ no error), so the available width is computed once, by yoga, from the parent.
92
+ Measured on the shipped component: 97 + 97 + 46 = 240/240, first divergence at
93
+ index -1.
94
+ - **A bracketed paste could type its own markers into the draft as `[200~hello
95
+ world`.** The startup `ESC[?2004h` is correct, but when a read boundary landed
96
+ inside the six-byte marker - ordinary on a Windows console, over SSH, or in tmux
97
+ under load - the 12 ms pending flush released the lone `ESC` as the Esc key (it
98
+ must never be held hostage), and the `[200~` that followed had nothing left to
99
+ recognise: `indexOf` needs the ESC. The filter now remembers a marker head it has
100
+ just released and, on the NEXT chunk only, splices it back in front for one
101
+ re-scan inside the same `feed()` - so the marker is consumed before its bytes
102
+ become text, `PENDING_FLUSH_MS` stays at 12 ms, and a new
103
+ `PASTE_MARKER_RESCAN_TTL_MS` (250 ms, structural, in `input/limits.ts`) bounds
104
+ when that is still believable. The visible garbage is gone in all three draft
105
+ measurements (`> hello world_`); the one residual is stated rather than hidden -
106
+ a write cannot be retracted, so the Esc keypress the release already produced
107
+ still happens, and a marker torn into three parts still leaves its middle
108
+ fragment behind (both recorded in `docs/diagnoses/.../analysis.md` §D-2).
109
+ - **A turn that only called a tool left a bare `●` bubble behind - on screen, in
110
+ the height table and in the saved session.** `turnStart` always appends an
111
+ assistant entry, so a tool-only turn, or one the user interrupted mid-stream,
112
+ left a bubble that `AssistantEntry` then refused to draw: the row was estimated
113
+ anyway (two for an aborted one, of which nothing drew the second), the scroll
114
+ position counted it, and `/save` exported it, so `/resume` brought it back. The
115
+ reducer now prunes a settled assistant entry with no text and no thinking, at
116
+ the three settle points only (`turnEnd`, `runEnd`, `abortMark`) - pruning earlier
117
+ would delete the live target of every `textDelta`, and the entry a `streamRestart`
118
+ is still filling in must survive. `abortMark` is the one settle path that does not
119
+ clear `streamingId` itself, so the helper returns the id and the caller writes it
120
+ back (I-L1-1); `runEnd` and `turnEnd` clear it anyway. A render-layer guard stays
121
+ as the second line of defence for a session file that already contains one.
122
+ - **An empty transcript still drew a full-height scrollbar track.** One rail
123
+ column of track glyphs from the second row down told the user there was more
124
+ below when there was nothing, and cost a column of width. `thumb === null` now
125
+ means no cells at all - the one-column BOX is still rendered, because the
126
+ reserved column (not the glyphs) is what keeps the content width constant; hiding
127
+ the column instead would re-wrap every line, change the measured height and feed
128
+ that back into the decision to show it.
129
+ - **Under `--no-color` the command palette's selected row was invisible, and a
130
+ bare `@` never opened file completion.** The selection was expressed only as
131
+ `inverse`, which is the identity at colour level 0 - and every `theme.*` colour is
132
+ `undefined` there too - so six commands looked identical and Enter was a guess
133
+ (`/clear` sits next to `/cwd`). The selected row now carries the same
134
+ `glyphs.caret` prefix the `/model` picker uses (`>` in the ASCII tier), with two
135
+ cells of padding on the unselected rows so no row changes width. Separately,
136
+ `README` has always promised that `@` opens a file-path completion popup while
137
+ the code required one more character: the `>= 1` gate and the `< 1` early return
138
+ are gone, and an empty `@` query lists the shallowest paths first (depth, then
139
+ length, then alphabetical) instead of the first twenty the filesystem happened to
140
+ hand back.
141
+ - **The completion menu was pinned at six rows on every terminal.** `App` handed
142
+ the popup a literal `6` while the frame budget had already computed the slot
143
+ capacity: a 30-row terminal showed 6 of 57 commands and 14 more hidden, so
144
+ browsing meant holding an arrow key. The cap is now derived from
145
+ `frameBudget.popupMaxHeight` through `POPUP_CAPACITY_SHARE` (2) in
146
+ `layout/budget.ts` - 6 rows at 20 lines, 11 at 30, 16 at 40. Handing over the
147
+ whole capacity was measured and rejected: `popupCapacity` reserves exactly one
148
+ message row, so at 20 rows it grants 13, the transcript collapses to a single row
149
+ and the TODO rail disappears. A divisor keeps the menu derived from the frame
150
+ budget instead of becoming a second authority beside it, and since it does not
151
+ depend on `popupRows` it cannot oscillate with it.
152
+ - **A fenced code block's language tag rendered as a body row, indented one cell
153
+ past the code.** The chip background and its padding spaces drew ` | ts` above
154
+ code rows starting `| export ...`, so the fence header read as an extra line of
155
+ source. It is now a muted bold TITLE on the same rail, aligned with the code, and
156
+ it deliberately keeps its own row: the height estimator counts raw markdown
157
+ lines and has no code-block branch, so the fence line must still occupy exactly
158
+ one. The fence grammar was too narrow as well - `(\w*)` rejected `c++`,
159
+ `objective-c` and four-backtick fences, degrading the whole block to prose and
160
+ swallowing the closing fence, and `split('\n')` left a `\r` on every CRLF code
161
+ line; a block now opens for 3+ backticks with any non-backtick info string, only
162
+ a run at least as long as its opener closes it, and the document is split
163
+ CRLF-safe.
164
+ - **A Markdown table rendered as space-separated plain text.** The `|---|` divider
165
+ was consumed by the parser to read alignment and never drawn, so a table had no
166
+ header marker, no column boundary and no rule - and under `--no-color`, where
167
+ bold is dropped too, nothing at all distinguished the header. `formatTable` now
168
+ returns `[header, divider, ...body]`, draws the rule from the caller's glyph and
169
+ separates columns with a visible `|` (both injected by `Markdown.tsx` from
170
+ `pickGlyphs`, so the parser stays ASCII-only and the pure default is `|`/`-`),
171
+ and pads by DISPLAY columns rather than `String.length`, which misaligned every
172
+ CJK cell. The separator costs one column per boundary; a table wide enough to
173
+ exceed the transcript is truncated rather than wrapped, which is the deliberate
174
+ trade recorded in §D-6.
175
+ - **A command that succeeded said nothing, and its notice then displaced
176
+ `Idle` / `Running` until the next one.** `/theme light` changed the SGR, `/todo
177
+ panel on` changed the layout, `/expand 1` with nothing to expand did nothing -
178
+ and the only feedback was the echoed command, so "it worked", "the argument was
179
+ wrong" and "the key was never received" looked identical. Worse, the body had
180
+ been reaching `status-layout` all along but only rendered on the second status
181
+ row, behind Ctrl+G and off by default, while the phase word was REPLACED by the
182
+ feedback label: after `/copy` the row said `Copied` and never went back to
183
+ `Idle`, which is the one thing that row exists to answer. Notification kinds
184
+ (`notice`, `copied`, `copy-sent`, `copying`) now keep the phase word and add the
185
+ body as their own field, clipped to 32 display cells; action kinds (`confirm`,
186
+ `stopping`, `force-stop`, `exit`, `clear-input`, `copy-error`, `copy-cleanup`)
187
+ still take the phase slot, because "what the next key does" IS the phase. The
188
+ body is dropped before the phase word would be, and a copy error no longer paints
189
+ `Idle` red.
190
+ - **`/help` turned 8 rows per PgDn on a 19-row window - 42% of a page.** Reading
191
+ the 93-row shortcut list took twelve presses and overlapped eleven rows every
192
+ time, while PgUp/PgDn in the transcript moved a whole page. The step is now
193
+ `overlayPageSize(overlayBodyRows(...))` - one window minus the two context rows
194
+ `scroll.pageSize` already keeps - and it reaches BOTH consumers, the keyboard
195
+ branch and the Shift+wheel notch (`getOverlayPage`), from the one function
196
+ `OverlayFrame` renders its window with, so the two cannot disagree.
197
+ - **On a narrow terminal the header's right-hand hints collided with the
198
+ truncated caption.** The left half is `wrap="truncate"` and draws its ellipsis in
199
+ the last cell it owns, and the hint column started in the very next cell, so 52
200
+ columns read `aragon...Details off` as one string and 60 columns read
201
+ `glm-5.^L branch`. The row now spends a new `HEADER_HINT_LIMITS.separatorCells`
202
+ (2) between the halves, taken out of both the left half's width and the planner's
203
+ budget - a separate constant from `gapCells`, which spaces two hint clauses
204
+ inside the right column, because one constant carrying two meanings is how a
205
+ later spacing change silently re-lays-out the whole row. The `^I Index`
206
+ thresholds move with it: 54 columns collapsed and 64 expanded, two later than
207
+ before.
208
+ - **A large paste arrived as runs of digits the moment it was sent - on screen,
209
+ in the model's input, in the prompt history and in the session file.**
210
+ `sanitisePaste` deleted the C0/C1 *introducer byte* and kept the parameter
211
+ bytes, so `ESC[38;2;18;52;86m` reached the transcript as the literal text
212
+ `[38;2;18;52;86m`. The composer collapses a large paste into
213
+ `[Pasted text #N +20 lines]`, so nothing was visible until submit expanded
214
+ it: a measured 20-line coloured log produced 80 runs of digits, and the same
215
+ string was what `/context`, the ↑ history and the session checkpoint stored.
216
+ Cleaning is now SEQUENCE-based and has one owner (new
217
+ `input/control-sequences.ts`, ASCII-only, no new dependency): a complete
218
+ escape goes as a unit, a truncated one loses its introducer and parameters
219
+ but never the character after them, an unterminated OSC/DCS/APC/PM/SOS goes
220
+ to the end of its line and never across it, and bare text that merely looks
221
+ like a sequence body (`[38;2m` with no ESC) is left exactly as it is.
222
+ `tools/terminal-output.ts`'s `stripAnsi` becomes a thin wrapper over the same
223
+ syntax, so a tool row and a paste cannot drift apart (both directions are
224
+ pinned by a cross-check test, and `terminal-output.test.ts` is unchanged).
225
+ A **single-line** paste carrying ESC - which `classifyChunk` sent down the
226
+ keystroke path with no cleaning at all - is now framed and cleaned like any
227
+ other paste; measured on the real component that path either put a live ESC
228
+ plus `[38;2;18;52;86m` into the draft or threw an uncaught `TypeError` inside
229
+ Ink's key parser. win32-input-mode records inside a frame are translated
230
+ rather than deleted, so a `?9001h` paste yields its characters instead of 15
231
+ visible digits per key (or, with cleaning but no translation, nothing at all).
232
+ - **A pasted TAB-indented block or TSV table lined up differently before and
233
+ after sending.** One byte had three readings: the composer painted
234
+ four-column tab stops, the transcript handed the raw `\t` to the terminal
235
+ (eight), and the exit replay counted code units. New `ui/display-tab.ts`
236
+ expands a TAB by DISPLAY column (`string-width`, so a `CJK<TAB>X` line lands
237
+ where the terminal paints it); the composer's stop is the terminal's eight, and
238
+ `UserEntry`, `QueuedEntry` and the exit replay expand at paint time only -
239
+ the stored payload keeps its `\t`, so the model still receives the original
240
+ indentation. `wrappedRows` counts display columns too (with an ASCII fast
241
+ path, without which a 10 000-entry transcript timed out), because an estimate
242
+ that reads a TAB as one column keeps the row short while the painted row
243
+ grows - the under-estimate that file names as the unsafe direction.
80
244
  - **`Ctrl+L` opened a window that failed with a PowerShell `ParserError` on
81
245
  Windows.** The script handed to `-EncodedCommand` was the argv joined with
82
246
  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. |
@@ -402,6 +402,36 @@ function releaseLiveTails(entries) {
402
402
  });
403
403
  return touched ? next : entries;
404
404
  }
405
+ /**
406
+ * Drop settled assistant entries that render nothing at all (defect 5).
407
+ *
408
+ * `turnStart` ALWAYS creates an entry, so a turn that only called a tool leaves a
409
+ * bubble behind that `AssistantEntry` then refuses to draw. A rendered absence is
410
+ * not the same thing as an absent entry: `virtual-window.ts:305` still estimates a
411
+ * row for it (two when `aborted`, and nothing draws the second), and `/save`
412
+ * exports a blank bubble. Settle is the only safe moment to prune — before it the
413
+ * entry is still the target of every `textDelta` / `thinkingDelta`.
414
+ *
415
+ * I-L1-1 — `streamingId` COMES BACK WITH THE ARRAY. If the dropped entry is the
416
+ * one that id pointed at, the id is cleared: leaving it set would point the whole
417
+ * reducer at an entry no `mapEntry` can ever match again, silently, because a
418
+ * `mapEntry` on a missing id is a no-op. `turnEnd` and `runEnd` clear the id
419
+ * themselves; `abortMark` does not, which is why the caller writes this back.
420
+ *
421
+ * Returns the SAME array when nothing was dropped, so every settled entry keeps
422
+ * its identity and `EntryView`'s memo boundary stays intact.
423
+ */
424
+ function pruneEmptyAssistant(entries, streamingId) {
425
+ const isEmptySettledAssistant = (entry) => entry.kind === 'assistant' &&
426
+ !entry.streaming &&
427
+ entry.text.trim() === '' &&
428
+ (entry.thinking ?? '').trim() === '';
429
+ const kept = entries.filter((entry) => !isEmptySettledAssistant(entry));
430
+ if (kept.length === entries.length)
431
+ return { entries, streamingId };
432
+ const liveGone = streamingId !== undefined && !kept.some((entry) => entry.id === streamingId);
433
+ return { entries: kept, streamingId: liveGone ? undefined : streamingId };
434
+ }
405
435
  function findToolEntryId(state, toolCallId) {
406
436
  for (let i = state.entries.length - 1; i >= 0; i -= 1) {
407
437
  const e = state.entries[i];
@@ -673,7 +703,10 @@ export function viewReducer(state, action) {
673
703
  // branch ever had. Two branches writing one number is what P0-1 was.
674
704
  usageTotal: addUsage(state.usageTotal, action.usage, action.costDelta),
675
705
  // The turn produced an answer, so whatever it had to retry, it recovered.
676
- ...settleRetryCard(state, entries, 'recovered'),
706
+ // Defect 5: prune AFTER the mapEntry above has settled the entry, so an
707
+ // assistant bubble this turn never filled in is gone before the retry card
708
+ // is settled and before the height table ever sees it.
709
+ ...settleRetryCard(state, pruneEmptyAssistant(entries, state.streamingId).entries, 'recovered'),
677
710
  };
678
711
  }
679
712
  case 'runEnd': {
@@ -681,6 +714,12 @@ export function viewReducer(state, action) {
681
714
  let entries = state.streamingId
682
715
  ? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, ...sealThinkingMs(e) } : e)
683
716
  : state.entries;
717
+ /*
718
+ * Defect 5, and BEFORE the silent-failure guard below so the guard and the
719
+ * retry settle both see the pruned array. `streamingId: undefined` is set in
720
+ * the returned state either way, so the returned id is not needed here.
721
+ */
722
+ entries = pruneEmptyAssistant(entries, state.streamingId).entries;
684
723
  let seq = state.seq;
685
724
  let droppedEntries = state.droppedEntries;
686
725
  // Silent-failure guard (R2): a swallowed throw shows nothing otherwise.
@@ -750,13 +789,19 @@ export function viewReducer(state, action) {
750
789
  const marked = state.streamingId
751
790
  ? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, aborted: true } : e)
752
791
  : state.entries;
792
+ // Defect 5 / I-L1-1: prune AFTER the entry is settled, and write the
793
+ // returned id back below -- `abortMark` is the one settle path that does
794
+ // NOT clear `streamingId`, so a prune without that write-back would leave
795
+ // it naming an entry this branch has just removed.
796
+ const pruned = pruneEmptyAssistant(marked, state.streamingId);
753
797
  return {
754
798
  ...state,
755
799
  aborted: true,
800
+ streamingId: pruned.streamingId,
756
801
  // An abort during a tool call is the COMMON way a tail is stranded --
757
802
  // `Esc` while `npm test` runs -- so this is the clause manual row 9
758
803
  // exercises (D-35 / AC-40).
759
- ...settleRetryCard(state, releaseLiveTails(marked), 'interrupted'),
804
+ ...settleRetryCard(state, releaseLiveTails(pruned.entries), 'interrupted'),
760
805
  };
761
806
  }
762
807
  // `teamEntryId` is dropped with the entries it pointed at. Keeping it would
@@ -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
+ }
@@ -39,6 +39,26 @@ export declare const PASTE_BURST_MS = 15;
39
39
  * which is the rule `MAX_PENDING_MOUSE_CHARS` already encodes for mouse reports.
40
40
  */
41
41
  export declare const PASTE_ASSEMBLY_MAX_MS = 2000;
42
+ /**
43
+ * How long a paste-marker head that was ALREADY released to the pass-through as
44
+ * ordinary text may still be recognised as the head of a marker (defect 2: the
45
+ * torn-marker rescue in `stdin-filter.ts`).
46
+ *
47
+ * STRUCTURAL, NOT POLICY: no config key exposes it, because it bounds what the
48
+ * byte stream can physically do -- a marker split across two reads -- and not
49
+ * what a user prefers. What it does NOT do is delay a keystroke: the 12 ms
50
+ * pending-flush window in `stdin-filter.ts` still releases a lone ESC exactly as
51
+ * before, and this window only governs whether that release may be spliced back
52
+ * in front of the next chunk so an assembled marker is consumed, not typed.
53
+ *
54
+ * THE FALSE POSITIVE IS THE TRADE-OFF, AND IT IS ACCEPTED. Firing the rescue
55
+ * wrongly needs the user to press Esc and then TYPE the literal characters
56
+ * `[200~` or `[201~` inside this window -- a sequence no keyboard produces, and
57
+ * at worst five characters of lost typing. Paying that buys a fix for visible
58
+ * `[200~` / `[201~` garbage in the draft, which is the failure the whole paste
59
+ * feature exists to prevent.
60
+ */
61
+ export declare const PASTE_MARKER_RESCAN_TTL_MS = 250;
42
62
  /**
43
63
  * The inline frame the filter writes into the stream Ink reads (D-3 / I-5).
44
64
  *
@@ -39,6 +39,26 @@ export const PASTE_BURST_MS = 15;
39
39
  * which is the rule `MAX_PENDING_MOUSE_CHARS` already encodes for mouse reports.
40
40
  */
41
41
  export const PASTE_ASSEMBLY_MAX_MS = 2_000;
42
+ /**
43
+ * How long a paste-marker head that was ALREADY released to the pass-through as
44
+ * ordinary text may still be recognised as the head of a marker (defect 2: the
45
+ * torn-marker rescue in `stdin-filter.ts`).
46
+ *
47
+ * STRUCTURAL, NOT POLICY: no config key exposes it, because it bounds what the
48
+ * byte stream can physically do -- a marker split across two reads -- and not
49
+ * what a user prefers. What it does NOT do is delay a keystroke: the 12 ms
50
+ * pending-flush window in `stdin-filter.ts` still releases a lone ESC exactly as
51
+ * before, and this window only governs whether that release may be spliced back
52
+ * in front of the next chunk so an assembled marker is consumed, not typed.
53
+ *
54
+ * THE FALSE POSITIVE IS THE TRADE-OFF, AND IT IS ACCEPTED. Firing the rescue
55
+ * wrongly needs the user to press Esc and then TYPE the literal characters
56
+ * `[200~` or `[201~` inside this window -- a sequence no keyboard produces, and
57
+ * at worst five characters of lost typing. Paying that buys a fix for visible
58
+ * `[200~` / `[201~` garbage in the draft, which is the failure the whole paste
59
+ * feature exists to prevent.
60
+ */
61
+ export const PASTE_MARKER_RESCAN_TTL_MS = 250;
42
62
  /**
43
63
  * The inline frame the filter writes into the stream Ink reads (D-3 / I-5).
44
64
  *