@aragon-agent/cli 0.6.27 → 0.6.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +164 -0
- package/README.md +2 -2
- package/dist/agent/reducer.js +47 -2
- package/dist/input/control-sequences.d.ts +71 -0
- package/dist/input/control-sequences.js +222 -0
- package/dist/input/limits.d.ts +20 -0
- package/dist/input/limits.js +20 -0
- package/dist/input/paste-parse.d.ts +21 -9
- package/dist/input/paste-parse.js +23 -11
- package/dist/input/stdin-filter.js +123 -9
- package/dist/tools/terminal-output.d.ts +8 -0
- package/dist/tools/terminal-output.js +10 -7
- 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/display-tab.d.ts +51 -0
- package/dist/ui/display-tab.js +82 -0
- package/dist/ui/editor-navigation.d.ts +11 -1
- package/dist/ui/editor-navigation.js +14 -3
- package/dist/ui/entries/QueuedEntry.js +4 -1
- package/dist/ui/entries/UserEntry.js +8 -1
- 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/layout/virtual-window.js +44 -11
- package/dist/ui/markdown-blocks.d.ts +26 -5
- package/dist/ui/markdown-blocks.js +110 -33
- package/dist/ui/paste-frames.d.ts +5 -1
- package/dist/ui/paste-frames.js +7 -2
- package/dist/ui/transcript-text.js +11 -2
- package/dist/ui/use-wheel-routing.d.ts +10 -7
- package/dist/ui/use-wheel-routing.js +11 -6
- package/package.json +2 -2
|
@@ -56,16 +56,28 @@ export declare function classifyChunk(text: string, minBurstChars?: number): 'pa
|
|
|
56
56
|
* newline in pasted text, and a lone LF is dropped by Ink's key parser
|
|
57
57
|
* entirely, so both have to be normalised here or a pasted line break either
|
|
58
58
|
* submits the message or silently vanishes.
|
|
59
|
-
* 2. Every
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
59
|
+
* 2. Every escape SEQUENCE is removed AS A SEQUENCE (`stripControlSequences`).
|
|
60
|
+
* Deleting only the introducer byte is what produced the reported defect:
|
|
61
|
+
* `ESC [ 3 8 ; 2 ; 1 8 ; 5 2 ; 8 6 m` minus its ESC is `[38;2;18;52;86m`,
|
|
62
|
+
* a run of digits Ink then writes to the terminal as ordinary text -- and it
|
|
63
|
+
* reached the model, the prompt history and the session file too, because
|
|
64
|
+
* this function is the only cleaning the payload ever gets.
|
|
65
|
+
* 3. Every remaining C0 byte except `\n` and `\t` is removed. Ink writes
|
|
66
|
+
* `<Text>` children to the terminal without escaping anything, so a
|
|
67
|
+
* surviving `\r` overwrites the row it is drawn on and a surviving `ESC`
|
|
68
|
+
* repaints the frame colours. Sequences are gone by this point, so what is
|
|
69
|
+
* left for this step is a stray byte, never half of one.
|
|
70
|
+
* 4. DEL and every C1 byte (U+0080..U+009F) go too, and that is NOT padding:
|
|
71
|
+
* U+009B is the single-byte CSI and U+0085 is NEL, and a terminal that
|
|
72
|
+
* decodes them acts on them exactly as it acts on `ESC [`. A file pasted out
|
|
73
|
+
* of a non-UTF-8 editor walks straight through a "strip C0" rule.
|
|
74
|
+
* 5. TAB is KEPT. Indentation is content, and it is what the model receives.
|
|
75
|
+
* Expanding it is the RENDERERS' job (`ui/display-tab.ts`), so the model
|
|
76
|
+
* keeps the original indentation while the screen, the composer and the
|
|
77
|
+
* terminal all agree on what column a TAB lands on.
|
|
67
78
|
*
|
|
68
79
|
* Applied to PAYLOAD ONLY, never to keystrokes, so `Ctrl+A` and Escape keep
|
|
69
|
-
* working. Non-ASCII text (CJK, emoji) is untouched.
|
|
80
|
+
* working. Non-ASCII text (CJK, emoji) is untouched. Idempotent, so the
|
|
81
|
+
* second pass in `ui/paste-frames.ts` stays a byte-identical no-op.
|
|
70
82
|
*/
|
|
71
83
|
export declare function sanitisePaste(raw: string): string;
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* `DECSET 2004`. Both are about what a keyboard can physically emit.
|
|
17
17
|
*/
|
|
18
18
|
import { PASTE_MIN_BURST_CHARS } from './limits.js';
|
|
19
|
+
import { stripControlSequences } from './control-sequences.js';
|
|
19
20
|
/** `DECSET 2004` wraps a paste in these two markers. */
|
|
20
21
|
export const PASTE_BEGIN_MARK = '\x1b[200~';
|
|
21
22
|
export const PASTE_END_MARK = '\x1b[201~';
|
|
@@ -76,20 +77,31 @@ export function classifyChunk(text, minBurstChars = PASTE_MIN_BURST_CHARS) {
|
|
|
76
77
|
* newline in pasted text, and a lone LF is dropped by Ink's key parser
|
|
77
78
|
* entirely, so both have to be normalised here or a pasted line break either
|
|
78
79
|
* submits the message or silently vanishes.
|
|
79
|
-
* 2. Every
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
80
|
+
* 2. Every escape SEQUENCE is removed AS A SEQUENCE (`stripControlSequences`).
|
|
81
|
+
* Deleting only the introducer byte is what produced the reported defect:
|
|
82
|
+
* `ESC [ 3 8 ; 2 ; 1 8 ; 5 2 ; 8 6 m` minus its ESC is `[38;2;18;52;86m`,
|
|
83
|
+
* a run of digits Ink then writes to the terminal as ordinary text -- and it
|
|
84
|
+
* reached the model, the prompt history and the session file too, because
|
|
85
|
+
* this function is the only cleaning the payload ever gets.
|
|
86
|
+
* 3. Every remaining C0 byte except `\n` and `\t` is removed. Ink writes
|
|
87
|
+
* `<Text>` children to the terminal without escaping anything, so a
|
|
88
|
+
* surviving `\r` overwrites the row it is drawn on and a surviving `ESC`
|
|
89
|
+
* repaints the frame colours. Sequences are gone by this point, so what is
|
|
90
|
+
* left for this step is a stray byte, never half of one.
|
|
91
|
+
* 4. DEL and every C1 byte (U+0080..U+009F) go too, and that is NOT padding:
|
|
92
|
+
* U+009B is the single-byte CSI and U+0085 is NEL, and a terminal that
|
|
93
|
+
* decodes them acts on them exactly as it acts on `ESC [`. A file pasted out
|
|
94
|
+
* of a non-UTF-8 editor walks straight through a "strip C0" rule.
|
|
95
|
+
* 5. TAB is KEPT. Indentation is content, and it is what the model receives.
|
|
96
|
+
* Expanding it is the RENDERERS' job (`ui/display-tab.ts`), so the model
|
|
97
|
+
* keeps the original indentation while the screen, the composer and the
|
|
98
|
+
* terminal all agree on what column a TAB lands on.
|
|
87
99
|
*
|
|
88
100
|
* Applied to PAYLOAD ONLY, never to keystrokes, so `Ctrl+A` and Escape keep
|
|
89
|
-
* working. Non-ASCII text (CJK, emoji) is untouched.
|
|
101
|
+
* working. Non-ASCII text (CJK, emoji) is untouched. Idempotent, so the
|
|
102
|
+
* second pass in `ui/paste-frames.ts` stays a byte-identical no-op.
|
|
90
103
|
*/
|
|
91
104
|
export function sanitisePaste(raw) {
|
|
92
|
-
return raw
|
|
93
|
-
.replace(/\r\n?/g, '\n')
|
|
105
|
+
return stripControlSequences(raw.replace(/\r\n?/g, '\n'))
|
|
94
106
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
|
|
95
107
|
}
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { PassThrough } from 'node:stream';
|
|
35
35
|
import { MAX_PENDING_MOUSE_CHARS, splitMouseEvents, isMousePrefix, } from './mouse-events.js';
|
|
36
|
-
import { ENTER_NEWLINE_FRAME, PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
|
|
37
|
-
import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
|
|
36
|
+
import { ENTER_NEWLINE_FRAME, INDEX_KEY_FRAME, PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MARKER_RESCAN_TTL_MS, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
|
|
37
|
+
import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, isPastePrefix, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
|
|
38
|
+
import { hasPasteOnlySequence } from './control-sequences.js';
|
|
38
39
|
import { splitEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
|
|
39
40
|
import { splitCsiUKeys, trailingCsiUPrefixLength, translateCsiUKey } from './csiu-keys.js';
|
|
40
41
|
import { splitWin32KeySequences, trailingWin32PrefixLength, translateWin32Key, } from './win32-input-mode.js';
|
|
@@ -96,6 +97,23 @@ export function createStdinFilter(real, features, bridge, onWarn) {
|
|
|
96
97
|
let paste = { kind: 'idle' };
|
|
97
98
|
let discardBracketed = false;
|
|
98
99
|
let outputBatch = null;
|
|
100
|
+
/**
|
|
101
|
+
* A marker head that has ALREADY been released to Ink, kept for one more
|
|
102
|
+
* chunk (defect 2: the torn-marker rescue in `onData`).
|
|
103
|
+
*
|
|
104
|
+
* WHY IT EXISTS. `flushPending` releases a lone ESC after 12 ms because the
|
|
105
|
+
* Esc key must not be held hostage. When the split point lands inside a paste
|
|
106
|
+
* marker -- `ESC` in one read and `[200~hello` in the next, which is ordinary
|
|
107
|
+
* for a Windows console, SSH or tmux under load -- `feed` can no longer find
|
|
108
|
+
* the marker and `[200~` is typed into the draft. Nothing downstream can undo
|
|
109
|
+
* that: the marker has to be recognised BEFORE its bytes become text.
|
|
110
|
+
*
|
|
111
|
+
* WHAT IT CANNOT DO, STATED HONESTLY. The released ESC has already reached Ink
|
|
112
|
+
* as an Esc key press and this filter cannot retract a write. The rescue
|
|
113
|
+
* guarantees the marker CHARACTERS never become draft text; it does NOT undo
|
|
114
|
+
* that keypress (an armed interrupt gesture, a dismissed popup).
|
|
115
|
+
*/
|
|
116
|
+
let rescuedPrefix = null;
|
|
99
117
|
const writeInput = (text) => {
|
|
100
118
|
if (disposed || outputBatch?.refused)
|
|
101
119
|
return;
|
|
@@ -206,8 +224,8 @@ export function createStdinFilter(real, features, bridge, onWarn) {
|
|
|
206
224
|
// The scan (section 5.1, and the ORDER below is the specification)
|
|
207
225
|
// -----------------------------------------------------------------------
|
|
208
226
|
/**
|
|
209
|
-
* Text that reached the pass-through:
|
|
210
|
-
* stripping, then Ink.
|
|
227
|
+
* Text that reached the pass-through: a sequence a KEYBOARD cannot produce
|
|
228
|
+
* first (R-2), then Tier 2 classification, then mouse stripping, then Ink.
|
|
211
229
|
*
|
|
212
230
|
* MOUSE PARSING IS SUSPENDED FOR THE DURATION OF ANY PASTE BODY (I-3), burst
|
|
213
231
|
* and bracketed alike. The Tier 2 population is precisely the terminals with
|
|
@@ -225,6 +243,23 @@ export function createStdinFilter(real, features, bridge, onWarn) {
|
|
|
225
243
|
burstTimer = arm(flushBurst, PASTE_BURST_MS);
|
|
226
244
|
return;
|
|
227
245
|
}
|
|
246
|
+
// A SEQUENCE A KEYBOARD CANNOT PRODUCE IS A PASTE, FRAMED OR NOT (R-2).
|
|
247
|
+
//
|
|
248
|
+
// WHY THE CLASSIFIER BELOW IS NOT ENOUGH. `classifyChunk` needs either a
|
|
249
|
+
// line break or a chunk with no control byte but TAB, and a SINGLE-LINE
|
|
250
|
+
// paste carrying ESC satisfies neither -- so it travels the keystroke path
|
|
251
|
+
// with `sanitisePaste` never called. Measured on the real component that
|
|
252
|
+
// cost either a draft holding `[38;2;18;52;86m` ALONGSIDE A LIVE ESC (which
|
|
253
|
+
// then reached the model, the history and the session file) or an uncaught
|
|
254
|
+
// `TypeError` in Ink's parser, because `parse-keypress.js` maps `ESC [ 3 2 m`
|
|
255
|
+
// to a ctrl key whose `name` is undefined. Framing it here routes it through
|
|
256
|
+
// the ONE cleaning path every other paste already takes.
|
|
257
|
+
if (features.paste && hasPasteOnlySequence(text)) {
|
|
258
|
+
paste = { kind: 'burst', body: text, bytes: byteLength(text) };
|
|
259
|
+
burstTimer = clearTimer(burstTimer);
|
|
260
|
+
burstTimer = arm(flushBurst, PASTE_BURST_MS);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
228
263
|
if (features.paste && classifyChunk(text) === 'paste') {
|
|
229
264
|
paste = { kind: 'burst', body: text, bytes: byteLength(text) };
|
|
230
265
|
burstTimer = clearTimer(burstTimer);
|
|
@@ -300,14 +335,47 @@ export function createStdinFilter(real, features, bridge, onWarn) {
|
|
|
300
335
|
}
|
|
301
336
|
}
|
|
302
337
|
};
|
|
338
|
+
/**
|
|
339
|
+
* win32-input-mode records that arrived INSIDE a paste frame (R-4).
|
|
340
|
+
*
|
|
341
|
+
* DATA FIDELITY, NOT POLISH. Every character of a `?9001h` session arrives as
|
|
342
|
+
* `ESC [ vk ; sc ; char ; down ; mods ; rep _`, and once the frame opens
|
|
343
|
+
* nothing translated them: `sanitisePaste` then removed each record's ESC and
|
|
344
|
+
* left 15 visible characters of digits per key. Translating them is also what
|
|
345
|
+
* keeps the fix honest in the OTHER direction -- a whole-sequence stripper
|
|
346
|
+
* deletes such a record outright, so "clean" would have meant the user's
|
|
347
|
+
* characters silently vanishing. A record carrying a key FRAME (modified
|
|
348
|
+
* Enter, Ctrl+I) becomes the byte that frame stands for, because a frame
|
|
349
|
+
* inside a body is content and never a key.
|
|
350
|
+
*/
|
|
351
|
+
const translateInPaste = (text) => {
|
|
352
|
+
if (!enhancedKeys || !text.includes('\x1b['))
|
|
353
|
+
return text;
|
|
354
|
+
let out = '';
|
|
355
|
+
for (const segment of splitWin32KeySequences(text)) {
|
|
356
|
+
if (segment.kind === 'text') {
|
|
357
|
+
out += segment.text;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const legacy = translateWin32Key(segment.record);
|
|
361
|
+
if (legacy === ENTER_NEWLINE_FRAME)
|
|
362
|
+
out += '\n';
|
|
363
|
+
else if (legacy === INDEX_KEY_FRAME)
|
|
364
|
+
out += '\t';
|
|
365
|
+
else
|
|
366
|
+
out += legacy;
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
};
|
|
303
370
|
const appendBracketed = (text) => {
|
|
304
371
|
if (paste.kind !== 'bracketed' || text.length === 0 || discardBracketed)
|
|
305
372
|
return;
|
|
306
|
-
const
|
|
373
|
+
const body = translateInPaste(text);
|
|
374
|
+
const bytes = paste.bytes + byteLength(body);
|
|
307
375
|
const overflow = paste.overflow || bytes > PASTE_MAX_BYTES;
|
|
308
376
|
paste = {
|
|
309
377
|
kind: 'bracketed',
|
|
310
|
-
body: overflow ? paste.body : paste.body +
|
|
378
|
+
body: overflow ? paste.body : paste.body + body,
|
|
311
379
|
bytes,
|
|
312
380
|
overflow,
|
|
313
381
|
};
|
|
@@ -366,6 +434,18 @@ export function createStdinFilter(real, features, bridge, onWarn) {
|
|
|
366
434
|
rest = rest.slice(first + PASTE_BEGIN_MARK.length);
|
|
367
435
|
}
|
|
368
436
|
};
|
|
437
|
+
/**
|
|
438
|
+
* Hand a held tail to the ordinary text path, remembering it when it could
|
|
439
|
+
* still be the head of a paste marker.
|
|
440
|
+
*
|
|
441
|
+
* THE TAIL IS RELEASED EXACTLY AS BEFORE. Remembering it costs no latency: a
|
|
442
|
+
* real Esc keypress still reaches Ink after `PENDING_FLUSH_MS`, and the
|
|
443
|
+
* remembered head is only ever consulted for the NEXT chunk.
|
|
444
|
+
*/
|
|
445
|
+
const flushTailAsText = (text) => {
|
|
446
|
+
rescuedPrefix = isPastePrefix(text) ? { text, at: Date.now() } : null;
|
|
447
|
+
handleOutsidePaste(text, false);
|
|
448
|
+
};
|
|
369
449
|
const flushPending = () => {
|
|
370
450
|
flushTimer = clearTimer(flushTimer);
|
|
371
451
|
if (pending.length === 0 || disposed)
|
|
@@ -374,20 +454,54 @@ export function createStdinFilter(real, features, bridge, onWarn) {
|
|
|
374
454
|
pending = '';
|
|
375
455
|
// A tail that never completed is ordinary text; it goes through the same
|
|
376
456
|
// scan so a burst already open keeps coalescing it.
|
|
377
|
-
|
|
457
|
+
flushTailAsText(text);
|
|
458
|
+
};
|
|
459
|
+
/**
|
|
460
|
+
* One-shot rescue of a marker whose ESC was already flushed as text.
|
|
461
|
+
*
|
|
462
|
+
* Returns the string `feed` should scan -- the remembered head followed by
|
|
463
|
+
* this chunk -- when the chunk COMPLETES that head into a whole marker, and
|
|
464
|
+
* `null` otherwise. On `null` the chunk travels the normal path: the head has
|
|
465
|
+
* already been emitted, so re-feeding it would type its bytes twice, and
|
|
466
|
+
* holding the chunk instead would swallow characters the user really typed.
|
|
467
|
+
*
|
|
468
|
+
* STARTS WITH, NOT CONTAINS. A marker completed at the junction starts at
|
|
469
|
+
* index 0 of the joined text; one starting anywhere later lies wholly inside
|
|
470
|
+
* `chunk` (the head holds an ESC at index 0 and nothing else that could begin
|
|
471
|
+
* a marker), and `feed` finds that on its own. Only the junction case needs
|
|
472
|
+
* re-scanning, and it is the only case where prepending cannot duplicate a
|
|
473
|
+
* byte that Ink has already seen.
|
|
474
|
+
*
|
|
475
|
+
* ONE-SHOT BY CONSTRUCTION: the remembered head is cleared here on every call,
|
|
476
|
+
* whether or not the rescue fired, so at most one chunk is ever re-scanned
|
|
477
|
+
* against it and no stale head can outlive its `PASTE_MARKER_RESCAN_TTL_MS`.
|
|
478
|
+
*/
|
|
479
|
+
const takeRescuedPrefix = (carried, chunk) => {
|
|
480
|
+
const rescue = rescuedPrefix;
|
|
481
|
+
rescuedPrefix = null;
|
|
482
|
+
if (rescue === null || carried.length > 0 || !features.paste)
|
|
483
|
+
return null;
|
|
484
|
+
if (Date.now() - rescue.at > PASTE_MARKER_RESCAN_TTL_MS)
|
|
485
|
+
return null;
|
|
486
|
+
const joined = rescue.text + chunk;
|
|
487
|
+
const completes = joined.startsWith(PASTE_BEGIN_MARK) || joined.startsWith(PASTE_END_MARK);
|
|
488
|
+
return completes ? joined : null;
|
|
378
489
|
};
|
|
379
490
|
const onData = (chunk) => {
|
|
380
491
|
if (disposed || finished)
|
|
381
492
|
return;
|
|
382
493
|
flushTimer = clearTimer(flushTimer);
|
|
383
494
|
const decoded = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
495
|
+
// Stripped on BOTH paths, because a NUL in a payload must never be able to
|
|
496
|
+
// forge a `PASTE_OPEN` frame boundary.
|
|
497
|
+
const cleaned = decoded.replace(/\u0000/g, '');
|
|
384
498
|
const carried = pending;
|
|
385
499
|
pending = '';
|
|
386
500
|
// A rejected paste must not leave earlier text or a later Enter executable.
|
|
387
501
|
const batch = { chunks: [], refused: false };
|
|
388
502
|
outputBatch = batch;
|
|
389
503
|
try {
|
|
390
|
-
feed(carried
|
|
504
|
+
feed(takeRescuedPrefix(carried, cleaned) ?? (carried + cleaned));
|
|
391
505
|
}
|
|
392
506
|
finally {
|
|
393
507
|
outputBatch = null;
|
|
@@ -417,7 +531,7 @@ export function createStdinFilter(real, features, bridge, onWarn) {
|
|
|
417
531
|
// input: past the ceiling the tail becomes ordinary text.
|
|
418
532
|
const text = pending;
|
|
419
533
|
pending = '';
|
|
420
|
-
|
|
534
|
+
flushTailAsText(text);
|
|
421
535
|
return;
|
|
422
536
|
}
|
|
423
537
|
flushTimer = arm(flushPending, PENDING_FLUSH_MS);
|
|
@@ -65,6 +65,14 @@ export interface SanitizeResult {
|
|
|
65
65
|
/**
|
|
66
66
|
* Remove every escape sequence and control byte except `\t` `\n` `\r`.
|
|
67
67
|
*
|
|
68
|
+
* SEQUENCE SYNTAX HAS ONE OWNER (`input/control-sequences.ts`); this function
|
|
69
|
+
* adds only the control-byte sweep a DISPLAY row wants. Both call sites in this
|
|
70
|
+
* module already cut an unfinished escape off into the carry before calling it
|
|
71
|
+
* (`partialEscapeStart`), so the shared one-shot rule for a truncated sequence
|
|
72
|
+
* is exactly what they need -- and it fixes AC-24's failure in place: the old
|
|
73
|
+
* two-byte rule consumed the `\x1b[` of an unfinished CSI and left the
|
|
74
|
+
* PARAMETERS behind, so `\x1b[31m` split across chunks rendered as `red 3`.
|
|
75
|
+
*
|
|
68
76
|
* Exported for the tests, and used by `toDisplayRow` as the second line of
|
|
69
77
|
* defence over a carry that ends mid-escape.
|
|
70
78
|
*/
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* interpreting cursor motion would mean owning a screen buffer. Neither is a
|
|
23
23
|
* presentation round's job.
|
|
24
24
|
*/
|
|
25
|
+
import { stripControlSequences } from '../input/control-sequences.js';
|
|
25
26
|
/** Columns a live row may occupy before it is clipped. */
|
|
26
27
|
export const LIVE_ROW_MAX_CHARS = 200;
|
|
27
28
|
/** Tab stop width, matching every terminal's default. */
|
|
@@ -57,22 +58,24 @@ export const TRUNCATION_MARK = '...';
|
|
|
57
58
|
* and holding it forever would pin the carry.
|
|
58
59
|
*/
|
|
59
60
|
const MAX_PARTIAL_ESCAPE = 64;
|
|
60
|
-
/** CSI: `\x1b[` params intermediates final. */
|
|
61
|
-
const CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
62
|
-
/** OSC: `\x1b]` ... terminated by BEL or ST. */
|
|
63
|
-
const OSC = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
|
|
64
|
-
/** The two-byte forms: `\x1b` followed by one character. */
|
|
65
|
-
const ESC_PAIR = /\x1b[\s\S]/g;
|
|
66
61
|
/** C1, plus every C0 control except `\t` `\n` `\r`. */
|
|
67
62
|
const CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
|
|
68
63
|
/**
|
|
69
64
|
* Remove every escape sequence and control byte except `\t` `\n` `\r`.
|
|
70
65
|
*
|
|
66
|
+
* SEQUENCE SYNTAX HAS ONE OWNER (`input/control-sequences.ts`); this function
|
|
67
|
+
* adds only the control-byte sweep a DISPLAY row wants. Both call sites in this
|
|
68
|
+
* module already cut an unfinished escape off into the carry before calling it
|
|
69
|
+
* (`partialEscapeStart`), so the shared one-shot rule for a truncated sequence
|
|
70
|
+
* is exactly what they need -- and it fixes AC-24's failure in place: the old
|
|
71
|
+
* two-byte rule consumed the `\x1b[` of an unfinished CSI and left the
|
|
72
|
+
* PARAMETERS behind, so `\x1b[31m` split across chunks rendered as `red 3`.
|
|
73
|
+
*
|
|
71
74
|
* Exported for the tests, and used by `toDisplayRow` as the second line of
|
|
72
75
|
* defence over a carry that ends mid-escape.
|
|
73
76
|
*/
|
|
74
77
|
export function stripAnsi(text) {
|
|
75
|
-
return text
|
|
78
|
+
return stripControlSequences(text).replace(CONTROLS, '');
|
|
76
79
|
}
|
|
77
80
|
/**
|
|
78
81
|
* Index at which a trailing INCOMPLETE escape begins, or `-1`.
|
package/dist/ui/App.js
CHANGED
|
@@ -46,14 +46,14 @@ import { useTerminalSize } from './layout/useTerminalSize.js';
|
|
|
46
46
|
import { useHeightStore } from './use-height-store.js';
|
|
47
47
|
import { useRenderGovernor } from './use-render-governor.js';
|
|
48
48
|
import { readScrollbarPerf, setPerfResetHook, setPerfSnapshotProvider, } from '../commands/perf.js';
|
|
49
|
-
import { buildFrameBudget } from './layout/budget.js';
|
|
50
|
-
import {
|
|
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
|