@aragon-agent/cli 0.6.28 → 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.
package/CHANGELOG.md CHANGED
@@ -77,6 +77,134 @@ 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.
80
208
  - **A large paste arrived as runs of digits the moment it was sent - on screen,
81
209
  in the model's input, in the prompt history and in the session file.**
82
210
  `sanitisePaste` deleted the C0/C1 *introducer byte* and kept the parameter
@@ -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
@@ -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
  *
@@ -33,8 +33,8 @@
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, INDEX_KEY_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
38
  import { hasPasteOnlySequence } from './control-sequences.js';
39
39
  import { splitEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
40
40
  import { splitCsiUKeys, trailingCsiUPrefixLength, translateCsiUKey } from './csiu-keys.js';
@@ -97,6 +97,23 @@ export function createStdinFilter(real, features, bridge, onWarn) {
97
97
  let paste = { kind: 'idle' };
98
98
  let discardBracketed = false;
99
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;
100
117
  const writeInput = (text) => {
101
118
  if (disposed || outputBatch?.refused)
102
119
  return;
@@ -417,6 +434,18 @@ export function createStdinFilter(real, features, bridge, onWarn) {
417
434
  rest = rest.slice(first + PASTE_BEGIN_MARK.length);
418
435
  }
419
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
+ };
420
449
  const flushPending = () => {
421
450
  flushTimer = clearTimer(flushTimer);
422
451
  if (pending.length === 0 || disposed)
@@ -425,20 +454,54 @@ export function createStdinFilter(real, features, bridge, onWarn) {
425
454
  pending = '';
426
455
  // A tail that never completed is ordinary text; it goes through the same
427
456
  // scan so a burst already open keeps coalescing it.
428
- 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;
429
489
  };
430
490
  const onData = (chunk) => {
431
491
  if (disposed || finished)
432
492
  return;
433
493
  flushTimer = clearTimer(flushTimer);
434
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, '');
435
498
  const carried = pending;
436
499
  pending = '';
437
500
  // A rejected paste must not leave earlier text or a later Enter executable.
438
501
  const batch = { chunks: [], refused: false };
439
502
  outputBatch = batch;
440
503
  try {
441
- feed(carried + decoded.replace(/\u0000/g, ''));
504
+ feed(takeRescuedPrefix(carried, cleaned) ?? (carried + cleaned));
442
505
  }
443
506
  finally {
444
507
  outputBatch = null;
@@ -468,7 +531,7 @@ export function createStdinFilter(real, features, bridge, onWarn) {
468
531
  // input: past the ceiling the tail becomes ordinary text.
469
532
  const text = pending;
470
533
  pending = '';
471
- handleOutsidePaste(text, false);
534
+ flushTailAsText(text);
472
535
  return;
473
536
  }
474
537
  flushTimer = arm(flushPending, PENDING_FLUSH_MS);
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
@@ -3,7 +3,7 @@ import { Box, Text } from 'ink';
3
3
  /** Columns reserved for the rail, glyph included. */
4
4
  export const GUTTER_WIDTH = 2;
5
5
  export function Gutter({ glyph, color, children }) {
6
- return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, width: GUTTER_WIDTH, children: _jsx(Text, { color: color, children: glyph }) }), _jsx(Box, { flexDirection: "column", flexShrink: 0, children: children })] }));
6
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, width: GUTTER_WIDTH, children: _jsx(Text, { color: color, children: glyph }) }), _jsx(Box, { flexDirection: "column", flexBasis: 0, flexGrow: 1, flexShrink: 0, children: children })] }));
7
7
  }
8
8
  /**
9
9
  * The one-row gap between turns: a dim rail stub rather than a blank line, so
@@ -35,6 +35,21 @@ interface OverlayFrameSelfManaged extends OverlayFrameBase {
35
35
  }
36
36
  export type OverlayFrameProps = OverlayFrameControlled | OverlayFrameSelfManaged;
37
37
  export declare function OverlayFrame(props: OverlayFrameProps): React.ReactElement;
38
+ /**
39
+ * Rows one PgUp/PgDn - and one Shift+wheel notch - turns inside a controlled
40
+ * overlay (defect 11).
41
+ *
42
+ * THIS IS THE OVERLAY'S `scroll.pageSize`, deliberately the same rule: one window
43
+ * minus `PAGE_CONTEXT_LINES` of overlap, so a reader keeps two rows of context
44
+ * across a page turn exactly as `PgDn` in the transcript does. It replaces a
45
+ * constant 8, which was 42% of `/help`'s 19-row window on a 100x24 terminal and
46
+ * made reading a 93-row shortcut list a twelve-keystroke exercise.
47
+ *
48
+ * THE WINDOW HEIGHT COMES FROM `overlayBodyRows`, which is the same expression
49
+ * `OverlayFrame` renders with - computed twice here would be the two-authorities
50
+ * defect B1 is fixing one component over.
51
+ */
52
+ export declare function overlayPageSize(maxRows: number, cols: number): number;
38
53
  /** Body rows shared by controlled overlays and self-managed focused lists. */
39
54
  export declare function overlayBodyRows(maxRows: number, cols: number): number;
40
55
  export {};
@@ -4,6 +4,7 @@ import { Box, Text } from 'ink';
4
4
  import stringWidth from 'string-width';
5
5
  import { pickGlyphs } from '../glyphs.js';
6
6
  import { sliceWindow } from './overlay-window.js';
7
+ import { pageSize } from './scroll.js';
7
8
  /** Below this width the round border costs more than it conveys (§5.2). */
8
9
  const BORDERLESS_COLS = 48;
9
10
  /** Title + footer + top and bottom border. */
@@ -68,6 +69,23 @@ export function OverlayFrame(props) {
68
69
  }
69
70
  return (_jsx(Box, { flexDirection: "column", flexShrink: 0, borderStyle: glyphs.boxStyle, borderColor: theme.primary, paddingX: 1, marginTop: 1, children: inner }));
70
71
  }
72
+ /**
73
+ * Rows one PgUp/PgDn - and one Shift+wheel notch - turns inside a controlled
74
+ * overlay (defect 11).
75
+ *
76
+ * THIS IS THE OVERLAY'S `scroll.pageSize`, deliberately the same rule: one window
77
+ * minus `PAGE_CONTEXT_LINES` of overlap, so a reader keeps two rows of context
78
+ * across a page turn exactly as `PgDn` in the transcript does. It replaces a
79
+ * constant 8, which was 42% of `/help`'s 19-row window on a 100x24 terminal and
80
+ * made reading a 93-row shortcut list a twelve-keystroke exercise.
81
+ *
82
+ * THE WINDOW HEIGHT COMES FROM `overlayBodyRows`, which is the same expression
83
+ * `OverlayFrame` renders with - computed twice here would be the two-authorities
84
+ * defect B1 is fixing one component over.
85
+ */
86
+ export function overlayPageSize(maxRows, cols) {
87
+ return pageSize(overlayBodyRows(maxRows, cols));
88
+ }
71
89
  /** Body rows shared by controlled overlays and self-managed focused lists. */
72
90
  export function overlayBodyRows(maxRows, cols) {
73
91
  if (!Number.isFinite(maxRows))
@@ -6,10 +6,17 @@ export const MIN_INDICATOR_COLS = 40;
6
6
  export function ScrollIndicator({ rows, thumb, dragging = false, theme, caps, }) {
7
7
  const glyphs = pickGlyphs(caps);
8
8
  const count = Math.max(0, Math.floor(rows) || 0);
9
+ // NO CELLS WITHOUT A THUMB (defect 6). `thumb === null` means the content fits,
10
+ // so there is no position to mark and a full-height column of track glyphs is
11
+ // pure noise next to an empty transcript. The BOX below is still rendered
12
+ // whatever the thumb is — see the feedback-loop note at the top of this file:
13
+ // the reserved column, not the glyphs, is what keeps the width constant.
9
14
  const cells = [];
10
- for (let i = 0; i < count; i += 1) {
11
- const onThumb = !!thumb && i >= thumb.start && i < thumb.start + thumb.size;
12
- cells.push(_jsx(Text, { color: onThumb ? theme.primary : theme.muted, bold: onThumb && dragging, children: onThumb ? glyphs.scrollThumb : glyphs.scrollTrack }, i));
15
+ if (thumb) {
16
+ for (let i = 0; i < count; i += 1) {
17
+ const onThumb = i >= thumb.start && i < thumb.start + thumb.size;
18
+ cells.push(_jsx(Text, { color: onThumb ? theme.primary : theme.muted, bold: onThumb && dragging, children: onThumb ? glyphs.scrollThumb : glyphs.scrollTrack }, i));
19
+ }
13
20
  }
14
21
  return (_jsx(Box, { flexDirection: "column", flexShrink: 0, width: 1, children: cells }));
15
22
  }
@@ -22,6 +22,21 @@ export interface FrameBudget {
22
22
  }
23
23
  /** Clamp the displayed draft, independently of any transcript or panel content. */
24
24
  export declare function clampDraftRows(rows: number, draftRows: number): number;
25
+ /**
26
+ * The share of `popupMaxHeight` a completion menu may actually occupy (defect 10).
27
+ *
28
+ * `popupCapacity` answers "how much COULD the editor slot take", and its floor is
29
+ * one message row - right for a picker that owns the frame, wrong for a menu that
30
+ * opens over a conversation. At 20 rows the whole capacity is 13, which leaves one
31
+ * transcript row and no TODO rail at all (measured, `todo-app-layout.test.tsx`).
32
+ * Half keeps the menu growing with the terminal - 6 rows at 20, 11 at 30, 16 at 40 -
33
+ * while the conversation and the rail keep their rows.
34
+ *
35
+ * A divisor rather than a row count or a fraction of the terminal: it is the only
36
+ * form that stays derived from the frame budget instead of becoming a second
37
+ * authority beside it, and it cannot drift when `popupCapacity` changes.
38
+ */
39
+ export declare const POPUP_CAPACITY_SHARE = 2;
25
40
  /** Total menu capacity, including its borders; reserve at least one message row. */
26
41
  export declare function popupCapacity(rows: number, draftRows: number, statusExpanded: boolean): number;
27
42
  /** Normalize terminal dimensions once and conserve every visible frame row. */
@@ -8,6 +8,21 @@ export function clampDraftRows(rows, draftRows) {
8
8
  const wanted = Number.isFinite(draftRows) ? Math.floor(draftRows) : 1;
9
9
  return Math.max(1, Math.min(wanted, draftMaxRows(rows)));
10
10
  }
11
+ /**
12
+ * The share of `popupMaxHeight` a completion menu may actually occupy (defect 10).
13
+ *
14
+ * `popupCapacity` answers "how much COULD the editor slot take", and its floor is
15
+ * one message row - right for a picker that owns the frame, wrong for a menu that
16
+ * opens over a conversation. At 20 rows the whole capacity is 13, which leaves one
17
+ * transcript row and no TODO rail at all (measured, `todo-app-layout.test.tsx`).
18
+ * Half keeps the menu growing with the terminal - 6 rows at 20, 11 at 30, 16 at 40 -
19
+ * while the conversation and the rail keep their rows.
20
+ *
21
+ * A divisor rather than a row count or a fraction of the terminal: it is the only
22
+ * form that stays derived from the frame budget instead of becoming a second
23
+ * authority beside it, and it cannot drift when `popupCapacity` changes.
24
+ */
25
+ export const POPUP_CAPACITY_SHARE = 2;
11
26
  /** Total menu capacity, including its borders; reserve at least one message row. */
12
27
  export function popupCapacity(rows, draftRows, statusExpanded) {
13
28
  return Math.max(0, rows - 1 - 1 - (statusExpanded ? 2 : 1)
@@ -36,6 +36,15 @@ export declare const HEADER_HINT_LIMITS: {
36
36
  readonly minLeftCells: 24;
37
37
  /** Fixed spacing between two hint clauses. */
38
38
  readonly gapCells: 2;
39
+ /**
40
+ * Blank cells between the header's left half and this hint column (defect 12).
41
+ *
42
+ * A SEPARATE CONSTANT FROM `gapCells`. That one spaces two hint clauses INSIDE
43
+ * the right column (`buildPlan` below); this one separates the session
44
+ * description from that column. One constant carrying two meanings is how a
45
+ * later change to clause spacing silently re-lays-out the whole row.
46
+ */
47
+ readonly separatorCells: 2;
39
48
  };
40
49
  export type HeaderHintId = 'branch' | 'index' | 'detail';
41
50
  export interface HeaderHintClause {
@@ -36,6 +36,15 @@ export const HEADER_HINT_LIMITS = {
36
36
  minLeftCells: 24,
37
37
  /** Fixed spacing between two hint clauses. */
38
38
  gapCells: 2,
39
+ /**
40
+ * Blank cells between the header's left half and this hint column (defect 12).
41
+ *
42
+ * A SEPARATE CONSTANT FROM `gapCells`. That one spaces two hint clauses INSIDE
43
+ * the right column (`buildPlan` below); this one separates the session
44
+ * description from that column. One constant carrying two meanings is how a
45
+ * later change to clause spacing silently re-lays-out the whole row.
46
+ */
47
+ separatorCells: 2,
39
48
  };
40
49
  /** The verbatim clause text (spec 3.8, appendix A). */
41
50
  const BRANCH_CLAUSE = { collapsed: '^L branch', expanded: '^L new branch' };
@@ -69,7 +78,11 @@ export function planHeaderHints(input) {
69
78
  return buildPlan([{ id: 'detail', text: detail }], scrollCells, true);
70
79
  }
71
80
  const ladder = ladderFor(input.statusExpanded);
72
- const budget = columns - HEADER_HINT_LIMITS.minLeftCells;
81
+ // THE SEPARATOR IS SPENT OUT OF THE SAME ROW (defect 12): left half + gap + this
82
+ // column have to add up to `columns`, so the gap comes off the budget instead of
83
+ // being assumed free.
84
+ const budget = columns - HEADER_HINT_LIMITS.minLeftCells
85
+ - HEADER_HINT_LIMITS.separatorCells;
73
86
  for (const rung of ladder) {
74
87
  const plan = buildPlan(rung, scrollCells, false);
75
88
  if (plan.cells <= budget)
@@ -90,6 +90,47 @@ function feedbackLabel(input, form) {
90
90
  };
91
91
  return labels[feedback.kind][form === 'full' ? 0 : form === 'short' ? 1 : 2];
92
92
  }
93
+ /**
94
+ * Kinds that are NOTIFICATIONS, not a phase word (defect 8 / 9).
95
+ *
96
+ * A notification says "something happened". An ACTION kind (`confirm`,
97
+ * `stopping`, `force-stop`, `exit`, `clear-input`, `copy-error`,
98
+ * `copy-cleanup`) says what the NEXT key does - that IS the phase slot's job, so
99
+ * those keep replacing it. Everything in this set gets its own field instead and
100
+ * `phase` is never displaced: `Idle` / `Running` is the one thing this row must
101
+ * always answer, and a notice that lands as a turn ends is exactly when a wrong
102
+ * answer misleads the user about whether the agent is still working.
103
+ */
104
+ const NOTIFICATION_KINDS = new Set([
105
+ 'notice', 'copied', 'copy-sent', 'copying',
106
+ ]);
107
+ /**
108
+ * The most cells a notification body may take on the primary row.
109
+ *
110
+ * The row is exactly one row high and measured, so the body is CLIPPED rather
111
+ * than wrapped. 32 cells carries `Theme set to light.` or `Todo panel off.`
112
+ * whole while leaving the phase word, the mode and the context gauge intact; a
113
+ * longer message still has the details row, where it is rendered in full.
114
+ */
115
+ const FEEDBACK_TEXT_CELLS = 32;
116
+ /**
117
+ * Clip `text` to at most `cells` display columns, never splitting a wide glyph.
118
+ *
119
+ * Local rather than `interaction-hints.clipStatusText`: that module already
120
+ * imports THIS one, and a cycle for a five-line guard is not worth it.
121
+ */
122
+ function clipFeedbackText(text, cells) {
123
+ const clean = cleanStatusText(text);
124
+ if (stringWidth(clean) <= cells)
125
+ return clean;
126
+ let clipped = '';
127
+ for (const char of clean) {
128
+ if (stringWidth(clipped + char) > cells)
129
+ break;
130
+ clipped += char;
131
+ }
132
+ return clipped;
133
+ }
93
134
  const thinkingCodes = { off: 'O', minimal: 'N', low: 'L', medium: 'M', high: 'H', xhigh: 'X' };
94
135
  /**
95
136
  * The mode word for the primary row: full rungs name the mode with
@@ -127,15 +168,35 @@ export function planPrimaryStatusFields(input) {
127
168
  const code = thinkingCodes[input.thinkingLevel] ?? '?';
128
169
  const pct = contextPercent(input.context);
129
170
  const speed = speedNumber(input);
171
+ // THE RUN'S OWN URGENCY, and the notification's, are two different things
172
+ // (defect 9). `tone` keeps today's meaning for an ACTION kind (it colours the
173
+ // phase word); a notification is painted with its own level and leaves the
174
+ // phase word to describe the run - a copy error must not make `Idle` red.
130
175
  const tone = input.feedback?.level === 'error' || input.runOutcome === 'failed'
131
176
  ? 'error' : input.feedback?.level === 'warn' || input.waitingForConfirmation || input.stopping ? 'warning' : 'normal';
177
+ const notification = !!input.feedback && NOTIFICATION_KINDS.has(input.feedback.kind);
178
+ const phaseTone = notification
179
+ ? input.runOutcome === 'failed' ? 'error'
180
+ : input.waitingForConfirmation || input.stopping ? 'warning' : 'normal'
181
+ : tone;
182
+ const feedbackTone = input.feedback?.level === 'error' ? 'error'
183
+ : input.feedback?.level === 'warn' ? 'warning' : 'normal';
132
184
  const build = (rung) => {
133
185
  const full = rung === 0;
134
- const label = feedbackLabel(input, full ? 'full' : rung >= 4 ? 'tiny' : 'short')
186
+ const form = full ? 'full' : rung >= 4 ? 'tiny' : 'short';
187
+ // THE BODY IS A FIELD OF ITS OWN (defect 8). The text has been in
188
+ // `feedback.text` all along; it only ever reached the screen on the SECOND
189
+ // status row, which is behind Ctrl+G and off by default. Rung 3 and tighter
190
+ // drop it: the ladder exists to keep the phase word, and a body that only fits
191
+ // by hiding `Running` would be the original defect back with extra steps.
192
+ const notice = notification && rung <= 2 && input.feedback
193
+ ? clipFeedbackText(input.feedback.text, FEEDBACK_TEXT_CELLS) : '';
194
+ const label = (notification ? undefined : feedbackLabel(input, form))
135
195
  ?? (full ? labels[1] : rung >= 4 ? labels[0].slice(0, 4) : labels[0]);
136
196
  const modeText = modeFieldText(input.mode, input.pendingMode, full);
137
197
  return statusLinePlan([
138
- statusField('phase', glyph + (rung >= 5 ? '' : ' ') + label, tone),
198
+ statusField('phase', glyph + (rung >= 5 ? '' : ' ') + label, phaseTone),
199
+ ...(notice ? [statusField('feedback', notice, feedbackTone)] : []),
139
200
  ...(modeText ? [statusField('mode', modeText, 'accent')] : []),
140
201
  statusField('context', (full ? 'Context ' : 'C') + pct),
141
202
  statusField('thinking', full ? 'Think ' + (thinkingCodes[input.thinkingLevel] ? input.thinkingLevel : '?')
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Markdown parsing layer (spec §4.7). Pure and React-free so the grammar can be
3
- * unit-tested without rendering anything.
3
+ * unit-tested without rendering anything. `string-width` is the only import and
4
+ * it is a pure function of its argument, so the module stays side-effect free.
4
5
  *
5
6
  * Scope is deliberately small — this is not CommonMark. It covers what a coding
6
7
  * agent's answers actually contain: fenced code, headings, lists, quotes, rules,
@@ -31,11 +32,31 @@ export declare function parseTableRow(line: string): string[] | null;
31
32
  export declare function isTableDivider(line: string): boolean;
32
33
  export declare function tableAlignments(dividerLine: string): TableBlock['align'];
33
34
  /**
34
- * Pad a parsed table into fixed-width columns. Returns one string per row —
35
- * no borders, because a bordered table inside an already-indented gutter wraps
36
- * on any realistic terminal.
35
+ * How the table is drawn. Both characters are injected by the renderer from
36
+ * `pickGlyphs(caps)` because this file is inside the ASCII-only scan
37
+ * (`glyphs.test.ts::inScope`): a Unicode box-drawing literal here would fail
38
+ * that guard.
37
39
  */
38
- export declare function formatTable(rows: string[][], align: TableBlock['align']): string[];
40
+ export interface TableFormatOptions {
41
+ /** Drawn between cells; `Markdown.tsx` passes `glyphs.railVertical`. */
42
+ separator?: string;
43
+ /** Repeated to a column's padded width; `Markdown.tsx` passes `glyphs.hrule`. */
44
+ rule?: string;
45
+ }
46
+ /**
47
+ * Pad a parsed table into fixed-width columns and draw the header rule.
48
+ *
49
+ * Returns `[header, divider, ...bodyRows]`, one string per rendered row, so
50
+ * index 0 is always the header and index 1 always the divider. The divider is
51
+ * rendered rather than swallowed because the parser consumes the source
52
+ * `|---|` row to read alignment: without it, a `--no-color` terminal showed a
53
+ * table as unseparated prose (bold is dropped at colour level 0, and the old
54
+ * two-space join gave no column boundary either).
55
+ *
56
+ * Padding is by DISPLAY columns, not `String.length`: a CJK cell is one code
57
+ * unit but two terminal columns, so `.length` misaligned every such table.
58
+ */
59
+ export declare function formatTable(rows: string[][], align: TableBlock['align'], options?: TableFormatOptions): string[];
39
60
  /**
40
61
  * One parsed top-level block.
41
62
  *
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Markdown parsing layer (spec §4.7). Pure and React-free so the grammar can be
3
- * unit-tested without rendering anything.
3
+ * unit-tested without rendering anything. `string-width` is the only import and
4
+ * it is a pure function of its argument, so the module stays side-effect free.
4
5
  *
5
6
  * Scope is deliberately small — this is not CommonMark. It covers what a coding
6
7
  * agent's answers actually contain: fenced code, headings, lists, quotes, rules,
7
8
  * tables, and inline emphasis / code / strikethrough.
8
9
  */
10
+ import stringWidth from 'string-width';
9
11
  const HEADING_RE = /^(#{1,6})\s+(.*)$/;
10
12
  /** `---`, `***` or `___`, three or more, nothing else on the line. */
11
13
  const RULE_RE = /^(?:-{3,}|\*{3,}|_{3,})$/;
@@ -46,53 +48,128 @@ export function tableAlignments(dividerLine) {
46
48
  return 'left';
47
49
  });
48
50
  }
51
+ /** Default column separator: pure ASCII, so this module holds no glyph literal. */
52
+ const DEFAULT_TABLE_SEPARATOR = '|';
53
+ /** Default header rule: pure ASCII for the same reason. */
54
+ const DEFAULT_TABLE_RULE = '-';
55
+ /** One space either side of the separator, so a boundary is unmistakable. */
56
+ const TABLE_CELL_GAP = ' ';
49
57
  /**
50
- * Pad a parsed table into fixed-width columns. Returns one string per row —
51
- * no borders, because a bordered table inside an already-indented gutter wraps
52
- * on any realistic terminal.
58
+ * Pad a parsed table into fixed-width columns and draw the header rule.
59
+ *
60
+ * Returns `[header, divider, ...bodyRows]`, one string per rendered row, so
61
+ * index 0 is always the header and index 1 always the divider. The divider is
62
+ * rendered rather than swallowed because the parser consumes the source
63
+ * `|---|` row to read alignment: without it, a `--no-color` terminal showed a
64
+ * table as unseparated prose (bold is dropped at colour level 0, and the old
65
+ * two-space join gave no column boundary either).
66
+ *
67
+ * Padding is by DISPLAY columns, not `String.length`: a CJK cell is one code
68
+ * unit but two terminal columns, so `.length` misaligned every such table.
53
69
  */
54
- export function formatTable(rows, align) {
70
+ export function formatTable(rows, align, options = {}) {
71
+ const separator = options.separator ?? DEFAULT_TABLE_SEPARATOR;
72
+ const rule = options.rule ?? DEFAULT_TABLE_RULE;
55
73
  const colCount = Math.max(...rows.map((r) => r.length), 0);
56
74
  const widths = [];
57
75
  for (let c = 0; c < colCount; c += 1) {
58
- widths[c] = Math.max(...rows.map((r) => (r[c] ?? '').length), 0);
76
+ widths[c] = Math.max(...rows.map((r) => displayColumns(r[c] ?? '')), 0);
59
77
  }
60
- return rows.map((row) => row
61
- .map((cell, c) => {
62
- const w = widths[c] ?? 0;
63
- const a = align[c] ?? 'left';
64
- if (a === 'right')
65
- return cell.padStart(w);
66
- if (a === 'center') {
67
- const total = w - cell.length;
68
- const left = Math.floor(total / 2);
69
- return ' '.repeat(left) + cell + ' '.repeat(total - left);
78
+ const gap = `${TABLE_CELL_GAP}${separator}${TABLE_CELL_GAP}`;
79
+ const body = rows.map((row) => padRow(row, widths, align).join(gap).trimEnd());
80
+ if (body.length === 0)
81
+ return [];
82
+ const divider = widths.map((w) => rule.repeat(w)).join(gap).trimEnd();
83
+ return [body[0], divider, ...body.slice(1)];
84
+ }
85
+ function padRow(row, widths, align) {
86
+ return row.map((cell, c) => padCell(cell, widths[c] ?? 0, align[c] ?? 'left'));
87
+ }
88
+ function padCell(cell, width, align) {
89
+ const pad = width - displayColumns(cell);
90
+ if (pad <= 0)
91
+ return cell;
92
+ const filler = ' '.repeat(pad);
93
+ if (align === 'right')
94
+ return filler + cell;
95
+ if (align === 'center') {
96
+ const left = Math.floor(pad / 2);
97
+ return `${' '.repeat(left)}${cell}${' '.repeat(pad - left)}`;
98
+ }
99
+ return cell + filler;
100
+ }
101
+ /**
102
+ * Printable ASCII with no TAB: one code unit is one cell, so the width table is
103
+ * not consulted at all.
104
+ *
105
+ * The same load-bearing fast path `virtual-window.displayColumns` carries, and for
106
+ * the same reason: `stringWidth` strips ANSI and walks the East-Asian width table
107
+ * per code point, while this runs once per CELL of every table row on every frame.
108
+ * Table cells are overwhelmingly ASCII, so the branch is taken almost always -
109
+ * and when it is not, the table is CJK and the table is exactly what must be used.
110
+ */
111
+ const PLAIN_ASCII = /^[\x20-\x7e]*$/;
112
+ /** Terminal columns occupied by `text` (wide glyphs count two). */
113
+ function displayColumns(text) {
114
+ return PLAIN_ASCII.test(text) ? text.length : stringWidth(text);
115
+ }
116
+ /**
117
+ * An opening fence is three or more backticks plus an optional info string.
118
+ *
119
+ * The old `(\w*)` info group was too narrow for real answers: ```c++ and
120
+ * ```objective-c did not open a block at all, so the body degraded to prose and
121
+ * the closing fence was then swallowed as a new empty code block. A backtick is
122
+ * the one character CommonMark forbids in an info string; everything else is
123
+ * accepted here and the language is simply the FIRST token.
124
+ */
125
+ const FENCE_OPEN_RE = /^(`{3,})[ \t]*([^`]*)$/;
126
+ /** A closing fence carries backticks only — no info string. */
127
+ const FENCE_CLOSE_RE = /^(`{3,})[ \t]*$/;
128
+ function parseFenceOpen(line) {
129
+ const m = FENCE_OPEN_RE.exec(line);
130
+ if (!m)
131
+ return null;
132
+ const info = (m[2] ?? '').trim();
133
+ return { count: m[1].length, lang: info.split(/\s+/)[0] ?? '' };
134
+ }
135
+ /** Backtick count of a line that could close a fence; 0 when it could not. */
136
+ function closingFenceLength(line) {
137
+ const m = FENCE_CLOSE_RE.exec(line);
138
+ return m ? m[1].length : 0;
139
+ }
140
+ /**
141
+ * Collect a fence body starting after the opening fence.
142
+ *
143
+ * A closer must carry AT LEAST as many backticks as its opener, so a
144
+ * four-backtick block may contain ``` lines — that is how a nested markdown
145
+ * example survives. A shorter run is content, not the end of the block.
146
+ */
147
+ function readFenceBody(lines, openIndex, openCount) {
148
+ const body = [];
149
+ let i = openIndex + 1;
150
+ while (i < lines.length) {
151
+ if (closingFenceLength((lines[i] ?? '').trim()) >= openCount) {
152
+ return { code: body.join('\n'), next: i + 1 };
70
153
  }
71
- return cell.padEnd(w);
72
- })
73
- .join(' ')
74
- .trimEnd());
154
+ body.push(lines[i] ?? '');
155
+ i += 1;
156
+ }
157
+ return { code: body.join('\n'), next: i };
75
158
  }
76
- /** A fence line: ```` ```lang ```` or a bare ```` ``` ````. */
77
- const FENCE_RE = /^```(\w*)\s*$/;
78
159
  /** Split a document into fenced code, pipe tables, and everything else. */
79
160
  export function parseMarkdownBlocks(text) {
80
- const lines = text.split('\n');
161
+ // CRLF-safe: a bare `split('\n')` left a '\r' on every code line, which then
162
+ // reached the highlighter and the display-width maths.
163
+ const lines = text.split(/\r?\n/);
81
164
  const blocks = [];
82
165
  let i = 0;
83
166
  while (i < lines.length) {
84
167
  const line = lines[i] ?? '';
85
- const fence = FENCE_RE.exec(line.trim());
168
+ const fence = parseFenceOpen(line.trim());
86
169
  if (fence) {
87
- const lang = fence[1] || '';
88
- const codeLines = [];
89
- i += 1;
90
- while (i < lines.length && !FENCE_RE.test((lines[i] ?? '').trim())) {
91
- codeLines.push(lines[i] ?? '');
92
- i += 1;
93
- }
94
- i += 1; // skip the closing fence
95
- blocks.push({ kind: 'code', code: codeLines.join('\n'), lang });
170
+ const { code, next } = readFenceBody(lines, i, fence.count);
171
+ blocks.push({ kind: 'code', code, lang: fence.lang });
172
+ i = next;
96
173
  continue;
97
174
  }
98
175
  // A pipe row followed by a divider row starts a table; consume the run.
@@ -22,13 +22,6 @@
22
22
  import type { Overlay } from '../agent/reducer.js';
23
23
  import type { MouseSource } from '../input/stdin-filter.js';
24
24
  import type { ScrollIntent } from './layout/scroll.js';
25
- /**
26
- * Rows a PgUp/PgDn — and a Shift+wheel notch — moves inside a controlled
27
- * overlay. Declared here rather than in `App.tsx` because both the keyboard
28
- * branch and the wheel branch need it and `App` imports this module; the other
29
- * direction would be a cycle.
30
- */
31
- export declare const OVERLAY_PAGE = 8;
32
25
  export interface WheelRoutingOptions {
33
26
  isPointerCaptured?: () => boolean;
34
27
  isActive?: () => boolean;
@@ -39,6 +32,16 @@ export interface WheelRoutingOptions {
39
32
  onScroll: (kind: ScrollIntent, repeat: number) => void;
40
33
  /** Signed row delta; the caller clamps at 0 the way the keyboard branch does. */
41
34
  onOverlayScroll: (delta: number) => void;
35
+ /**
36
+ * Rows one Shift+wheel notch turns inside a controlled overlay (defect 11).
37
+ *
38
+ * A GETTER, read at event time for the same reason `getOverlay` is: the window
39
+ * height changes with the terminal, with `statusExpanded` and with the rail, and
40
+ * a value captured when the subscription was built is the value that goes stale.
41
+ * `App` answers with `overlayPageSize(...)`, the same function its keyboard
42
+ * branch uses, so the wheel and `PgDn` can never disagree.
43
+ */
44
+ getOverlayPage?: () => number;
42
45
  }
43
46
  export interface WheelRouting {
44
47
  /**
@@ -21,12 +21,15 @@
21
21
  */
22
22
  import { useCallback, useEffect, useRef } from 'react';
23
23
  /**
24
- * Rows a PgUp/PgDn — and a Shift+wheel notch — moves inside a controlled
25
- * overlay. Declared here rather than in `App.tsx` because both the keyboard
26
- * branch and the wheel branch need it and `App` imports this module; the other
27
- * direction would be a cycle.
24
+ * Page rows used ONLY when the host supplies no `getOverlayPage`.
25
+ *
26
+ * `App` always supplies one (defect 11): the step is the overlay's visible window
27
+ * minus two rows of context, so `PgDn` in `/help` turns a page instead of 42% of
28
+ * one. This fallback exists so a host that owns no overlay window - this module's
29
+ * own routing tests are the only such host - still gets a page rather than
30
+ * silently degrading a Shift+wheel to a three-row notch.
28
31
  */
29
- export const OVERLAY_PAGE = 8;
32
+ const FALLBACK_OVERLAY_PAGE = 8;
30
33
  /** Transcript rows per notch. The terminal's own convention. */
31
34
  const CONTENT_LINES_PER_NOTCH = 3;
32
35
  /** Overlay rows per notch. Overlay content is short; a page needs Shift. */
@@ -113,7 +116,9 @@ export function useWheelRouting(options) {
113
116
  // D-3: the row is not consulted here either. Behind an overlay the
114
117
  // composer is inactive and the overlay is the only scrollable thing on
115
118
  // screen, so a notch anywhere scrolls it.
116
- const step = event.shift ? OVERLAY_PAGE : OVERLAY_LINES_PER_NOTCH;
119
+ const step = event.shift
120
+ ? current.getOverlayPage?.() ?? FALLBACK_OVERLAY_PAGE
121
+ : OVERLAY_LINES_PER_NOTCH;
117
122
  current.onOverlayScroll(event.dir === 'up' ? -step : step);
118
123
  return;
119
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.28",
3
+ "version": "0.6.29",
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.40",
59
+ "@aragon-agent/core": "^0.2.41",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",