@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 +128 -0
- package/dist/agent/reducer.js +47 -2
- package/dist/input/limits.d.ts +20 -0
- package/dist/input/limits.js +20 -0
- package/dist/input/stdin-filter.js +68 -5
- package/dist/ui/App.js +34 -6
- package/dist/ui/AutocompletePopup.js +1 -1
- package/dist/ui/Header.js +7 -2
- package/dist/ui/Markdown.js +30 -3
- package/dist/ui/PromptInput.js +41 -3
- package/dist/ui/Transcript.js +7 -0
- package/dist/ui/layout/Gutter.js +1 -1
- package/dist/ui/layout/OverlayFrame.d.ts +15 -0
- package/dist/ui/layout/OverlayFrame.js +18 -0
- package/dist/ui/layout/ScrollIndicator.js +10 -3
- package/dist/ui/layout/budget.d.ts +15 -0
- package/dist/ui/layout/budget.js +15 -0
- package/dist/ui/layout/header-hints.d.ts +9 -0
- package/dist/ui/layout/header-hints.js +14 -1
- package/dist/ui/layout/status-layout.js +63 -2
- package/dist/ui/markdown-blocks.d.ts +26 -5
- package/dist/ui/markdown-blocks.js +110 -33
- package/dist/ui/use-wheel-routing.d.ts +10 -7
- package/dist/ui/use-wheel-routing.js +11 -6
- package/package.json +2 -2
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
|
package/dist/agent/reducer.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
package/dist/input/limits.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/input/limits.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 -
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/ui/Markdown.js
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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, {
|
|
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) {
|
package/dist/ui/PromptInput.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
package/dist/ui/Transcript.js
CHANGED
|
@@ -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
|
package/dist/ui/layout/Gutter.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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. */
|
package/dist/ui/layout/budget.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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] ?? '')
|
|
76
|
+
widths[c] = Math.max(...rows.map((r) => displayColumns(r[c] ?? '')), 0);
|
|
59
77
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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 =
|
|
168
|
+
const fence = parseFenceOpen(line.trim());
|
|
86
169
|
if (fence) {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
i
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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",
|