@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
@@ -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';
37
- import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
36
+ import { ENTER_NEWLINE_FRAME, INDEX_KEY_FRAME, PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MARKER_RESCAN_TTL_MS, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
37
+ import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, isPastePrefix, 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';
@@ -96,6 +97,23 @@ export function createStdinFilter(real, features, bridge, onWarn) {
96
97
  let paste = { kind: 'idle' };
97
98
  let discardBracketed = false;
98
99
  let outputBatch = null;
100
+ /**
101
+ * A marker head that has ALREADY been released to Ink, kept for one more
102
+ * chunk (defect 2: the torn-marker rescue in `onData`).
103
+ *
104
+ * WHY IT EXISTS. `flushPending` releases a lone ESC after 12 ms because the
105
+ * Esc key must not be held hostage. When the split point lands inside a paste
106
+ * marker -- `ESC` in one read and `[200~hello` in the next, which is ordinary
107
+ * for a Windows console, SSH or tmux under load -- `feed` can no longer find
108
+ * the marker and `[200~` is typed into the draft. Nothing downstream can undo
109
+ * that: the marker has to be recognised BEFORE its bytes become text.
110
+ *
111
+ * WHAT IT CANNOT DO, STATED HONESTLY. The released ESC has already reached Ink
112
+ * as an Esc key press and this filter cannot retract a write. The rescue
113
+ * guarantees the marker CHARACTERS never become draft text; it does NOT undo
114
+ * that keypress (an armed interrupt gesture, a dismissed popup).
115
+ */
116
+ let rescuedPrefix = null;
99
117
  const writeInput = (text) => {
100
118
  if (disposed || outputBatch?.refused)
101
119
  return;
@@ -206,8 +224,8 @@ export function createStdinFilter(real, features, bridge, onWarn) {
206
224
  // The scan (section 5.1, and the ORDER below is the specification)
207
225
  // -----------------------------------------------------------------------
208
226
  /**
209
- * Text that reached the pass-through: Tier 2 classification, then mouse
210
- * stripping, then Ink.
227
+ * Text that reached the pass-through: a sequence a KEYBOARD cannot produce
228
+ * first (R-2), then Tier 2 classification, then mouse stripping, then Ink.
211
229
  *
212
230
  * MOUSE PARSING IS SUSPENDED FOR THE DURATION OF ANY PASTE BODY (I-3), burst
213
231
  * and bracketed alike. The Tier 2 population is precisely the terminals with
@@ -225,6 +243,23 @@ export function createStdinFilter(real, features, bridge, onWarn) {
225
243
  burstTimer = arm(flushBurst, PASTE_BURST_MS);
226
244
  return;
227
245
  }
246
+ // A SEQUENCE A KEYBOARD CANNOT PRODUCE IS A PASTE, FRAMED OR NOT (R-2).
247
+ //
248
+ // WHY THE CLASSIFIER BELOW IS NOT ENOUGH. `classifyChunk` needs either a
249
+ // line break or a chunk with no control byte but TAB, and a SINGLE-LINE
250
+ // paste carrying ESC satisfies neither -- so it travels the keystroke path
251
+ // with `sanitisePaste` never called. Measured on the real component that
252
+ // cost either a draft holding `[38;2;18;52;86m` ALONGSIDE A LIVE ESC (which
253
+ // then reached the model, the history and the session file) or an uncaught
254
+ // `TypeError` in Ink's parser, because `parse-keypress.js` maps `ESC [ 3 2 m`
255
+ // to a ctrl key whose `name` is undefined. Framing it here routes it through
256
+ // the ONE cleaning path every other paste already takes.
257
+ if (features.paste && hasPasteOnlySequence(text)) {
258
+ paste = { kind: 'burst', body: text, bytes: byteLength(text) };
259
+ burstTimer = clearTimer(burstTimer);
260
+ burstTimer = arm(flushBurst, PASTE_BURST_MS);
261
+ return;
262
+ }
228
263
  if (features.paste && classifyChunk(text) === 'paste') {
229
264
  paste = { kind: 'burst', body: text, bytes: byteLength(text) };
230
265
  burstTimer = clearTimer(burstTimer);
@@ -300,14 +335,47 @@ export function createStdinFilter(real, features, bridge, onWarn) {
300
335
  }
301
336
  }
302
337
  };
338
+ /**
339
+ * win32-input-mode records that arrived INSIDE a paste frame (R-4).
340
+ *
341
+ * DATA FIDELITY, NOT POLISH. Every character of a `?9001h` session arrives as
342
+ * `ESC [ vk ; sc ; char ; down ; mods ; rep _`, and once the frame opens
343
+ * nothing translated them: `sanitisePaste` then removed each record's ESC and
344
+ * left 15 visible characters of digits per key. Translating them is also what
345
+ * keeps the fix honest in the OTHER direction -- a whole-sequence stripper
346
+ * deletes such a record outright, so "clean" would have meant the user's
347
+ * characters silently vanishing. A record carrying a key FRAME (modified
348
+ * Enter, Ctrl+I) becomes the byte that frame stands for, because a frame
349
+ * inside a body is content and never a key.
350
+ */
351
+ const translateInPaste = (text) => {
352
+ if (!enhancedKeys || !text.includes('\x1b['))
353
+ return text;
354
+ let out = '';
355
+ for (const segment of splitWin32KeySequences(text)) {
356
+ if (segment.kind === 'text') {
357
+ out += segment.text;
358
+ continue;
359
+ }
360
+ const legacy = translateWin32Key(segment.record);
361
+ if (legacy === ENTER_NEWLINE_FRAME)
362
+ out += '\n';
363
+ else if (legacy === INDEX_KEY_FRAME)
364
+ out += '\t';
365
+ else
366
+ out += legacy;
367
+ }
368
+ return out;
369
+ };
303
370
  const appendBracketed = (text) => {
304
371
  if (paste.kind !== 'bracketed' || text.length === 0 || discardBracketed)
305
372
  return;
306
- const bytes = paste.bytes + byteLength(text);
373
+ const body = translateInPaste(text);
374
+ const bytes = paste.bytes + byteLength(body);
307
375
  const overflow = paste.overflow || bytes > PASTE_MAX_BYTES;
308
376
  paste = {
309
377
  kind: 'bracketed',
310
- body: overflow ? paste.body : paste.body + text,
378
+ body: overflow ? paste.body : paste.body + body,
311
379
  bytes,
312
380
  overflow,
313
381
  };
@@ -366,6 +434,18 @@ export function createStdinFilter(real, features, bridge, onWarn) {
366
434
  rest = rest.slice(first + PASTE_BEGIN_MARK.length);
367
435
  }
368
436
  };
437
+ /**
438
+ * Hand a held tail to the ordinary text path, remembering it when it could
439
+ * still be the head of a paste marker.
440
+ *
441
+ * THE TAIL IS RELEASED EXACTLY AS BEFORE. Remembering it costs no latency: a
442
+ * real Esc keypress still reaches Ink after `PENDING_FLUSH_MS`, and the
443
+ * remembered head is only ever consulted for the NEXT chunk.
444
+ */
445
+ const flushTailAsText = (text) => {
446
+ rescuedPrefix = isPastePrefix(text) ? { text, at: Date.now() } : null;
447
+ handleOutsidePaste(text, false);
448
+ };
369
449
  const flushPending = () => {
370
450
  flushTimer = clearTimer(flushTimer);
371
451
  if (pending.length === 0 || disposed)
@@ -374,20 +454,54 @@ export function createStdinFilter(real, features, bridge, onWarn) {
374
454
  pending = '';
375
455
  // A tail that never completed is ordinary text; it goes through the same
376
456
  // scan so a burst already open keeps coalescing it.
377
- handleOutsidePaste(text, false);
457
+ flushTailAsText(text);
458
+ };
459
+ /**
460
+ * One-shot rescue of a marker whose ESC was already flushed as text.
461
+ *
462
+ * Returns the string `feed` should scan -- the remembered head followed by
463
+ * this chunk -- when the chunk COMPLETES that head into a whole marker, and
464
+ * `null` otherwise. On `null` the chunk travels the normal path: the head has
465
+ * already been emitted, so re-feeding it would type its bytes twice, and
466
+ * holding the chunk instead would swallow characters the user really typed.
467
+ *
468
+ * STARTS WITH, NOT CONTAINS. A marker completed at the junction starts at
469
+ * index 0 of the joined text; one starting anywhere later lies wholly inside
470
+ * `chunk` (the head holds an ESC at index 0 and nothing else that could begin
471
+ * a marker), and `feed` finds that on its own. Only the junction case needs
472
+ * re-scanning, and it is the only case where prepending cannot duplicate a
473
+ * byte that Ink has already seen.
474
+ *
475
+ * ONE-SHOT BY CONSTRUCTION: the remembered head is cleared here on every call,
476
+ * whether or not the rescue fired, so at most one chunk is ever re-scanned
477
+ * against it and no stale head can outlive its `PASTE_MARKER_RESCAN_TTL_MS`.
478
+ */
479
+ const takeRescuedPrefix = (carried, chunk) => {
480
+ const rescue = rescuedPrefix;
481
+ rescuedPrefix = null;
482
+ if (rescue === null || carried.length > 0 || !features.paste)
483
+ return null;
484
+ if (Date.now() - rescue.at > PASTE_MARKER_RESCAN_TTL_MS)
485
+ return null;
486
+ const joined = rescue.text + chunk;
487
+ const completes = joined.startsWith(PASTE_BEGIN_MARK) || joined.startsWith(PASTE_END_MARK);
488
+ return completes ? joined : null;
378
489
  };
379
490
  const onData = (chunk) => {
380
491
  if (disposed || finished)
381
492
  return;
382
493
  flushTimer = clearTimer(flushTimer);
383
494
  const decoded = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
495
+ // Stripped on BOTH paths, because a NUL in a payload must never be able to
496
+ // forge a `PASTE_OPEN` frame boundary.
497
+ const cleaned = decoded.replace(/\u0000/g, '');
384
498
  const carried = pending;
385
499
  pending = '';
386
500
  // A rejected paste must not leave earlier text or a later Enter executable.
387
501
  const batch = { chunks: [], refused: false };
388
502
  outputBatch = batch;
389
503
  try {
390
- feed(carried + decoded.replace(/\u0000/g, ''));
504
+ feed(takeRescuedPrefix(carried, cleaned) ?? (carried + cleaned));
391
505
  }
392
506
  finally {
393
507
  outputBatch = null;
@@ -417,7 +531,7 @@ export function createStdinFilter(real, features, bridge, onWarn) {
417
531
  // input: past the ceiling the tail becomes ordinary text.
418
532
  const text = pending;
419
533
  pending = '';
420
- handleOutsidePaste(text, false);
534
+ flushTailAsText(text);
421
535
  return;
422
536
  }
423
537
  flushTimer = arm(flushPending, PENDING_FLUSH_MS);
@@ -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`.
package/dist/ui/App.js CHANGED
@@ -46,14 +46,14 @@ import { useTerminalSize } from './layout/useTerminalSize.js';
46
46
  import { useHeightStore } from './use-height-store.js';
47
47
  import { useRenderGovernor } from './use-render-governor.js';
48
48
  import { readScrollbarPerf, setPerfResetHook, setPerfSnapshotProvider, } from '../commands/perf.js';
49
- import { buildFrameBudget } from './layout/budget.js';
50
- import { OVERLAY_PAGE, useWheelRouting } from './use-wheel-routing.js';
49
+ import { buildFrameBudget, POPUP_CAPACITY_SHARE } from './layout/budget.js';
50
+ import { useWheelRouting } from './use-wheel-routing.js';
51
51
  import { INDEX_KEY_FRAME } from '../input/limits.js';
52
52
  import { INDEX_SKILL_NAME, buildIndexConfirmSummary, hasProjectIndex, indexBuildInvocationArgs, } from '../commands/index-build.js';
53
53
  import { installConsoleBridge } from './console-bridge.js';
54
54
  import { publishExitSnapshot } from './exit-snapshot.js';
55
55
  import { publishSessionView } from './session-bridge.js';
56
- import { OverlayFrame } from './layout/OverlayFrame.js';
56
+ import { OverlayFrame, overlayPageSize } from './layout/OverlayFrame.js';
57
57
  import { helpRows } from './overlays/HelpOverlay.js';
58
58
  import { ModelPicker } from './overlays/ModelPicker.js';
59
59
  import { createModelProbe } from '../agent/model-probe.js';
@@ -148,6 +148,23 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
148
148
  const [statusExpanded, setStatusExpanded] = useState(false);
149
149
  const dimensions = buildFrameBudget({ ...size, draftRows, popupRows, statusExpanded });
150
150
  const { rows, cols } = dimensions;
151
+ /**
152
+ * Rows one PgUp/PgDn - and one Shift+wheel notch - turns inside a controlled
153
+ * overlay (defect 11).
154
+ *
155
+ * ONE VALUE FOR BOTH BRANCHES, derived from the overlay's real window: a
156
+ * constant 8 was 42% of `/help`'s 19-row window, so a shortcut list needed
157
+ * twelve keystrokes and overlapped itself by eleven rows on every turn. The
158
+ * wheel branch gets it through `getOverlayPage`, so the two can never disagree.
159
+ *
160
+ * MEASURED FROM THIS BUDGET, NOT `frameBudget` BELOW. The two agree on every
161
+ * frame except the one that opens an overlay with the completion menu still
162
+ * reported (`frameBudget` zeroes `popupRows` there), and that exception only
163
+ * makes the real window LARGER - so this page can under-turn for one frame but
164
+ * can never step past the window and skip content. The unsafe direction is the
165
+ * one that matters, and it is unreachable.
166
+ */
167
+ const overlayPage = overlayPageSize(dimensions.viewportRows, cols);
151
168
  const tooSmall = dimensions.inactive;
152
169
  // --- Render governor (tui-render-performance L4). ------------------------
153
170
  //
@@ -1764,6 +1781,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1764
1781
  isPointerCaptured: () => terminal?.scrollbar?.controller?.isCaptured() ?? false,
1765
1782
  isActive: () => !tooSmall,
1766
1783
  getOverlay: () => stateRef.current.overlay,
1784
+ getOverlayPage: () => overlayPage,
1767
1785
  onScroll: scrollBy,
1768
1786
  onOverlayScroll: (delta) => setOverlayScroll((n) => Math.max(0, n + delta)),
1769
1787
  });
@@ -1975,12 +1993,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1975
1993
  const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan' || overlay === 'queue';
1976
1994
  if (key.pageUp) {
1977
1995
  if (controlled)
1978
- setOverlayScroll((n) => Math.max(0, n - OVERLAY_PAGE));
1996
+ setOverlayScroll((n) => Math.max(0, n - overlayPage));
1979
1997
  return;
1980
1998
  }
1981
1999
  if (key.pageDown) {
1982
2000
  if (controlled)
1983
- setOverlayScroll((n) => n + OVERLAY_PAGE);
2001
+ setOverlayScroll((n) => n + overlayPage);
1984
2002
  return;
1985
2003
  }
1986
2004
  // `settings` keeps Up/Down for field movement — a high-frequency action
@@ -2319,7 +2337,17 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2319
2337
  },
2320
2338
  };
2321
2339
  // Completion shares the fixed editor slot and its frame budget.
2322
- const popupMaxRows = 6;
2340
+ //
2341
+ // THE FRAME BUDGET, NOT A LITERAL 6 (defect 10), BUT NOT ALL OF IT EITHER.
2342
+ // `popupMaxHeight` is the capacity `buildFrameBudget` already computed, and a
2343
+ // second, smaller constant here is what made 57 commands browsable six at a time
2344
+ // on a 30-row terminal. Handing over the WHOLE capacity is wrong in the other
2345
+ // direction: `popupCapacity` reserves exactly one message row, so at 20 rows it
2346
+ // grants 13, the transcript collapses to a single row and the TODO rail
2347
+ // disappears - measured, `todo-app-layout.test.tsx` catches it. Half the granted
2348
+ // capacity keeps the menu growing with the terminal (6 rows at 20, 11 at 30, 16
2349
+ // at 40) while the conversation and the rail keep theirs.
2350
+ const popupMaxRows = Math.floor(frameBudget.popupMaxHeight / POPUP_CAPACITY_SHARE);
2323
2351
  // WHICH KEY THE HINT ROW NAMES (shift-tab-mode-toggle-still-dead-on-windows,
2324
2352
  // C3-1). Derived from `vtInputWarning` rather than from a probe of its own,
2325
2353
  // because that prop is already the single answer to "can this console deliver
@@ -12,6 +12,6 @@ export function AutocompletePopup({ items, selected, theme, caps, maxRows, layou
12
12
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, borderStyle: glyphs.boxStyle, borderColor: theme.border, paddingX: 1, children: [visible.map((item, i) => {
13
13
  const idx = windowStart + i;
14
14
  const active = idx === selected;
15
- return (_jsxs(Text, { inverse: active, wrap: "truncate", children: [_jsx(Text, { color: active ? undefined : theme.primary, children: item.label.replace(/\s+/g, ' ') }), item.hint ? _jsxs(Text, { color: theme.muted, children: [" ", item.hint.replace(/\s+/g, ' ')] }) : null] }, idx));
15
+ return (_jsxs(Text, { inverse: active, wrap: "truncate", children: [_jsx(Text, { color: active ? theme.accent : theme.muted, children: active ? `${glyphs.caret} ` : ' ' }), _jsx(Text, { color: active ? undefined : theme.primary, children: item.label.replace(/\s+/g, ' ') }), item.hint ? _jsxs(Text, { color: theme.muted, children: [" ", item.hint.replace(/\s+/g, ' ')] }) : null] }, idx));
16
16
  }), showMore && _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [" +", moreBelow, " more"] })] }));
17
17
  }
package/dist/ui/Header.js CHANGED
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { basename } from 'node:path';
3
3
  import { Box, Text } from 'ink';
4
4
  import { Logo } from './Logo.js';
5
- import { planHeaderHints } from './layout/header-hints.js';
5
+ import { planHeaderHints, HEADER_HINT_LIMITS } from './layout/header-hints.js';
6
6
  export function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns = 80, statusExpanded = false, scrolledLines = 0, overlayOpen = false }) {
7
7
  // The right-hand hints are planned, not assembled (tui-branch-terminal-shortcut
8
8
  // 3.9 / D-11). `plan.text` is exactly `plan.cells` wide and already carries the
@@ -10,5 +10,10 @@ export function Header({ cwd, provider, model, hasKey, variant, theme, caps, col
10
10
  // hint by guessing one cell short - and the ladder that decides which hints
11
11
  // survive a narrow terminal lives in a pure function with its own tests.
12
12
  const plan = planHeaderHints({ columns, statusExpanded, overlayOpen, scrolledLines });
13
- return _jsxs(Box, { flexDirection: "row", height: 1, width: columns, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { width: Math.max(0, columns - plan.cells), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Logo, { variant: variant === 'mini' ? 'mini' : 'bar', theme: theme, caps: caps }), variant !== 'mini' && _jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.accent, children: [' ', provider, ":", model] }), _jsxs(Text, { color: theme.muted, children: [' ', basename(cwd) || cwd] }), _jsxs(Text, { color: hasKey ? theme.toolDone : theme.toolRunning, children: [' ', hasKey ? theme.symbols.keyOn : theme.symbols.keyOff] })] })] }) }), _jsx(Box, { width: plan.cells, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: plan.text }) })] });
13
+ // THE TWO HALVES ARE SEPARATED BY CELLS, NOT BY LUCK (defect 12). The left half
14
+ // is `wrap="truncate"`, which draws its ellipsis in the LAST cell it owns; with
15
+ // the right half starting in the very next cell a narrow terminal read
16
+ // `aragon...Details off` as one string. The blank column comes out of the left
17
+ // half's width, so the row still spans exactly `columns`.
18
+ return _jsxs(Box, { flexDirection: "row", height: 1, width: columns, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { width: Math.max(0, columns - plan.cells - HEADER_HINT_LIMITS.separatorCells), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Logo, { variant: variant === 'mini' ? 'mini' : 'bar', theme: theme, caps: caps }), variant !== 'mini' && _jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.accent, children: [' ', provider, ":", model] }), _jsxs(Text, { color: theme.muted, children: [' ', basename(cwd) || cwd] }), _jsxs(Text, { color: hasKey ? theme.toolDone : theme.toolRunning, children: [' ', hasKey ? theme.symbols.keyOn : theme.symbols.keyOff] })] })] }) }), _jsx(Box, { width: HEADER_HINT_LIMITS.separatorCells, flexShrink: 0 }), _jsx(Box, { width: plan.cells, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: plan.text }) })] });
14
19
  }
@@ -16,22 +16,49 @@ function renderBlock(block, key, theme, glyphs) {
16
16
  case 'code':
17
17
  return (_jsx(CodeBlock, { code: block.code, lang: block.lang, theme: theme, glyphs: glyphs }, `b${key}`));
18
18
  case 'table': {
19
- const formatted = formatTable(block.rows, block.align);
20
- return (_jsx(Box, { flexDirection: "column", children: formatted.map((row, r) => (_jsx(Text, { wrap: "truncate", bold: r === 0, color: r === 0 ? theme.accent : undefined, children: row }, r))) }, `b${key}`));
19
+ // Glyphs are injected rather than hardcoded: this module is inside the
20
+ // ASCII-only scan, and the pure parser defaults to ASCII `|` / `-`.
21
+ const formatted = formatTable(block.rows, block.align, {
22
+ separator: glyphs.railVertical,
23
+ rule: glyphs.hrule,
24
+ });
25
+ return (_jsx(Box, { flexDirection: "column", children: formatted.map((row, r) => (_jsx(Text, { wrap: "truncate", bold: r === 0, color: tableRowColor(r, theme), children: row }, r))) }, `b${key}`));
21
26
  }
22
27
  default:
23
28
  return _jsx(InlineLine, { line: block.text, theme: theme, glyphs: glyphs }, `b${key}`);
24
29
  }
25
30
  }
31
+ /**
32
+ * Table row 0 is the header (bold + accent); row 1 is the divider drawn under it.
33
+ *
34
+ * The divider must stay distinguishable at colour level 0, where bold is dropped
35
+ * to nothing, so it is MUTED and never bold: in `--no-color` the `----` run is
36
+ * itself the signal, and in a coloured tier the weight difference separates the
37
+ * header from the rule beneath it.
38
+ */
39
+ function tableRowColor(index, theme) {
40
+ if (index === 0)
41
+ return theme.accent;
42
+ if (index === 1)
43
+ return theme.muted;
44
+ return undefined;
45
+ }
26
46
  /**
27
47
  * Fenced code. The round border is gone (§4.3): nested inside a tool card it
28
48
  * produced a box inside a box and squeezed an 80-column terminal down to 74
29
49
  * usable columns. A left rail plus the language tag conveys the same "this is
30
50
  * code" boundary for 1 column and 0 rows.
51
+ *
52
+ * The language tag is a muted TITLE, not a body row: it used to carry a chip
53
+ * background and one padding space either side of the text, so it rendered as
54
+ * ` | ts` above code rows that start at `| export ...` — a body line that
55
+ * happened to be the language. It keeps its own row on purpose: the height
56
+ * estimator (`virtual-window.estimateEntryRows`) counts RAW markdown lines and
57
+ * has no code-block branch, so the fence line must still occupy exactly 1 row.
31
58
  */
32
59
  function CodeBlock({ code, lang, theme, glyphs, }) {
33
60
  const rendered = highlightCached(code, lang);
34
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [lang.length > 0 && (_jsx(Text, { color: theme.chip.fg, backgroundColor: theme.chip.bg, children: ` ${lang} ` })), _jsx(Text, { children: rendered })] }));
61
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [lang.length > 0 && (_jsx(Text, { bold: true, color: theme.muted, children: lang })), _jsx(Text, { children: rendered })] }));
35
62
  }
36
63
  /** Six heading levels used to render identically; now they carry three weights. */
37
64
  function headingStyle(level, theme) {
@@ -234,6 +234,31 @@ export function fileTokenAt(buffer, cursor) {
234
234
  end += 1;
235
235
  return { start, end, query: buffer.slice(start + 1, end) };
236
236
  }
237
+ /**
238
+ * How many `@file` candidates reach the popup.
239
+ *
240
+ * The popup has its own row budget; this is the list it windows. Twenty ranked
241
+ * paths are enough to spot the one you meant, and it bounds the array that every
242
+ * draft edit re-renders.
243
+ */
244
+ const FILE_MATCH_LIMIT = 20;
245
+ /**
246
+ * Segments in a path - the first ranking key for an EMPTY `@` query (defect 10).
247
+ *
248
+ * A bare `@` matches every file in the tree, and `tinyGlob` returns them in
249
+ * whatever order the filesystem walked, so opening the popup without a rank gave
250
+ * a correct-but-arbitrary twenty. Shallowest first, then shortest, then
251
+ * alphabetical: the paths a user is reaching for (`src/`, a root config) come
252
+ * before `packages/cli/src/ui/layout/deep/thing.ts`.
253
+ */
254
+ function pathDepth(path) {
255
+ let depth = 1;
256
+ for (const char of path) {
257
+ if (char === '/' || char === '\\')
258
+ depth += 1;
259
+ }
260
+ return depth;
261
+ }
237
262
  /** A leading C0 control byte or ESC sequence should never be inserted raw. */
238
263
  function isControlSeq(input) {
239
264
  if (input.length === 0)
@@ -253,7 +278,11 @@ export function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, st
253
278
  const slashSug = dismissed ? null : slashSuggestions(buffer, commands);
254
279
  const fileTok = fileTokenAt(buffer, cursor);
255
280
  const fileQuery = fileTok ? fileTok.query : null;
256
- const fileActive = !dismissed && !!fileTok && fileTok.query.length >= 1 && fileMatches.length > 0;
281
+ // A BARE `@` OPENS THE POPUP (defect 10). The `query.length >= 1` gate here and
282
+ // the `< 1` early return in the scan effect below were the whole reason a lone
283
+ // `@` showed nothing while `README.md` promises "@ opens a file-path completion
284
+ // popup". An empty query is a legitimate one: it lists the shallowest paths.
285
+ const fileActive = !dismissed && !!fileTok && fileMatches.length > 0;
257
286
  const popupKind = slashSug && slashSug.length > 0 ? 'slash' : fileActive ? 'file' : null;
258
287
  const popupItems = popupKind === 'slash'
259
288
  ? slashSug
@@ -263,7 +292,7 @@ export function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, st
263
292
  const clampedSel = popupItems.length > 0 ? Math.min(sel, popupItems.length - 1) : 0;
264
293
  // --- Debounced `@file` scan. ---------------------------------------------
265
294
  useEffect(() => {
266
- if (fileQuery === null || fileQuery.length < 1) {
295
+ if (fileQuery === null) {
267
296
  setFileMatches([]);
268
297
  return;
269
298
  }
@@ -279,7 +308,16 @@ export function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, st
279
308
  if (cancelled)
280
309
  return;
281
310
  const q = fileQuery.toLowerCase();
282
- setFileMatches(matches.filter((m) => m.toLowerCase().includes(q)).slice(0, 20));
311
+ // RANKED, NOT WALK ORDER (defect 10). An empty query matches every path
312
+ // in the tree, so without a sort the popup for a bare `@` was
313
+ // correct-but-arbitrary - the first twenty the filesystem happened to
314
+ // hand back.
315
+ const ranked = matches
316
+ .filter((m) => m.toLowerCase().includes(q))
317
+ .sort((a, b) => pathDepth(a) - pathDepth(b)
318
+ || a.length - b.length || (a < b ? -1 : a > b ? 1 : 0))
319
+ .slice(0, FILE_MATCH_LIMIT);
320
+ setFileMatches(ranked);
283
321
  })
284
322
  .catch(() => {
285
323
  if (!cancelled)
@@ -65,6 +65,13 @@ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion,
65
65
  // place by `turnStart` (5.2.3).
66
66
  return frame(glyphs.queued, theme.muted, _jsx(QueuedEntry, { text: entry.text, theme: theme, caps: caps }));
67
67
  case 'assistant': {
68
+ // Render-layer backstop for the reducer prune (defect 5) - the reducer is the
69
+ // source of truth, and this only catches a settled, entirely empty bubble
70
+ // that reached here some other way. Returned BEFORE `frame(...)`, so neither
71
+ // the glyph row nor the separation row is drawn.
72
+ if (!entry.streaming && entry.text.trim() === '' && (entry.thinking ?? '').trim() === '') {
73
+ return null;
74
+ }
68
75
  const animate = entry.streaming && !reducedMotion && caps.unicode;
69
76
  return frame(animate ? _jsx(Spinner, { type: "dots" }) : glyphs.assistant, theme.primary, _jsx(AssistantEntry, { text: entry.text, thinking: entry.thinking, thinkingOpen: entry.thinkingOpen, thinkingVisible: thinkingVisible, streaming: entry.streaming, aborted: entry.aborted,
70
77
  // `EntryViewImpl` hands each renderer EXPLICIT SCALAR PROPS, so