@kolisachint/hoocode-tui 0.5.66 → 0.5.68
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/dist/components/editor.d.ts +20 -18
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +54 -58
- package/dist/components/editor.js.map +1 -1
- package/dist/components/frame.d.ts +117 -0
- package/dist/components/frame.d.ts.map +1 -0
- package/dist/components/frame.js +203 -0
- package/dist/components/frame.js.map +1 -0
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js +8 -2
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js +7 -2
- package/dist/components/settings-list.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +9 -4
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +14 -2
- package/dist/keybindings.js.map +1 -1
- package/dist/mouse.d.ts +75 -0
- package/dist/mouse.d.ts.map +1 -0
- package/dist/mouse.js +115 -0
- package/dist/mouse.js.map +1 -0
- package/dist/terminal.d.ts +30 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +49 -0
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts +286 -0
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +642 -1
- package/dist/tui.js.map +1 -1
- package/dist/undo-stack.d.ts +39 -6
- package/dist/undo-stack.d.ts.map +1 -1
- package/dist/undo-stack.js +59 -6
- package/dist/undo-stack.js.map +1 -1
- package/package.json +1 -1
package/dist/tui.js
CHANGED
|
@@ -5,9 +5,11 @@ import * as fs from "node:fs";
|
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import { performance } from "node:perf_hooks";
|
|
8
|
+
import { stripVTControlCharacters } from "node:util";
|
|
8
9
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
10
|
+
import { mouseSequenceLength, parseMouseEvent } from "./mouse.js";
|
|
9
11
|
import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
10
|
-
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
|
|
12
|
+
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, truncateToWidth, visibleWidth, } from "./utils.js";
|
|
11
13
|
const KITTY_SEQUENCE_PREFIX = "\x1b_G";
|
|
12
14
|
function extractKittyImageIds(line) {
|
|
13
15
|
const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
|
|
@@ -47,6 +49,35 @@ export const CURSOR_MARKER = "\x1b_pi:c\x07";
|
|
|
47
49
|
*/
|
|
48
50
|
const HIDE_CURSOR = "\x1b[?25l";
|
|
49
51
|
const SHOW_CURSOR = "\x1b[?25h";
|
|
52
|
+
/**
|
|
53
|
+
* How far one wheel notch moves the pinned view.
|
|
54
|
+
*
|
|
55
|
+
* Three lines is what terminals, pagers and browsers have settled on, and the
|
|
56
|
+
* agreement is the point: a wheel that moves a different distance here than in
|
|
57
|
+
* every other window is the kind of wrongness people feel without being able to
|
|
58
|
+
* name it.
|
|
59
|
+
*/
|
|
60
|
+
const WHEEL_LINES = 3;
|
|
61
|
+
/**
|
|
62
|
+
* The indicator the tui draws when the app has not supplied its own.
|
|
63
|
+
*
|
|
64
|
+
* Reverse video rather than a colour, because this package has no theme and a
|
|
65
|
+
* hard-coded colour is the one thing guaranteed to clash with whichever one the
|
|
66
|
+
* app is using. It leads with the position — the question a pinned reader
|
|
67
|
+
* actually has — and spends what is left on the keys, dropping them on a narrow
|
|
68
|
+
* terminal rather than truncating the numbers.
|
|
69
|
+
*/
|
|
70
|
+
function defaultScrollStatus(status) {
|
|
71
|
+
const position = `${status.top}–${status.bottom}/${status.total}`;
|
|
72
|
+
const where = status.atTop ? " top" : "";
|
|
73
|
+
const keys = "↑↓ line · PgUp/PgDn page · esc live";
|
|
74
|
+
const left = ` ${position}${where} `;
|
|
75
|
+
// Measured in columns, not characters: the arrows and the separator are one
|
|
76
|
+
// cell each but a rebind could put anything in here, and a row that is one
|
|
77
|
+
// cell too wide wraps into the window above it.
|
|
78
|
+
const body = visibleWidth(left) + visibleWidth(keys) + 1 <= status.width ? `${left}${keys} ` : left;
|
|
79
|
+
return `\x1b[7m${truncateToWidth(body, status.width, "", true)}\x1b[0m`;
|
|
80
|
+
}
|
|
50
81
|
/** Parse a SizeValue into absolute value given a reference size */
|
|
51
82
|
function parseSizeValue(value, referenceSize) {
|
|
52
83
|
if (value === undefined)
|
|
@@ -95,6 +126,30 @@ export class Container {
|
|
|
95
126
|
child.invalidate?.();
|
|
96
127
|
}
|
|
97
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Where each direct child's output starts, in rows, from the last render.
|
|
131
|
+
*
|
|
132
|
+
* Read off the memo rather than recomputed, so asking is a walk over the
|
|
133
|
+
* children's cached line arrays and never a re-render. Undefined before the
|
|
134
|
+
* first render, or at a different width than the caller has in mind — both
|
|
135
|
+
* cases mean "no answer", not "zero".
|
|
136
|
+
*
|
|
137
|
+
* This is what lets something outside the tree point at a row inside it: a
|
|
138
|
+
* component's offset within its container, plus that container's offset at
|
|
139
|
+
* the root, is its absolute row in the buffer the viewport windows over.
|
|
140
|
+
*/
|
|
141
|
+
childRowOffsets(width) {
|
|
142
|
+
const memo = this.renderMemo;
|
|
143
|
+
if (!memo || memo.width !== width)
|
|
144
|
+
return undefined;
|
|
145
|
+
const offsets = new Array(memo.refs.length);
|
|
146
|
+
let row = 0;
|
|
147
|
+
for (let i = 0; i < memo.refs.length; i++) {
|
|
148
|
+
offsets[i] = row;
|
|
149
|
+
row += memo.refs[i].length;
|
|
150
|
+
}
|
|
151
|
+
return offsets;
|
|
152
|
+
}
|
|
98
153
|
render(width) {
|
|
99
154
|
const n = this.children.length;
|
|
100
155
|
const memo = this.renderMemo;
|
|
@@ -119,6 +174,70 @@ export class Container {
|
|
|
119
174
|
return lines;
|
|
120
175
|
}
|
|
121
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* A child that can be taken off screen without disturbing the diff.
|
|
179
|
+
*
|
|
180
|
+
* Hiding a component naively — returning `[]` from its render — is one of the
|
|
181
|
+
* more expensive things you can do to this renderer. `Container.render` and the
|
|
182
|
+
* root's flat cache both decide "did this subtree change" by **array identity**,
|
|
183
|
+
* so a fresh `[]` every frame reads as a change every frame: the memo is
|
|
184
|
+
* dropped, the buffer is re-flattened, and a dirty range is reported for a
|
|
185
|
+
* component that is not even drawn. One frozen array, returned every time,
|
|
186
|
+
* makes a hidden slot free instead.
|
|
187
|
+
*
|
|
188
|
+
* The child is not rendered at all while hidden, which is the other half of the
|
|
189
|
+
* saving — a hidden footer costs nothing to keep hidden. That means a child
|
|
190
|
+
* whose `render` advances an animation or maintains a cache will be paused, not
|
|
191
|
+
* merely invisible; it catches up when shown again. Chrome (footers, panels,
|
|
192
|
+
* status rows) is fine with that. A spinner is not, so do not wrap one.
|
|
193
|
+
*/
|
|
194
|
+
export class Slot {
|
|
195
|
+
component;
|
|
196
|
+
/** Shared across every hidden slot: identity is all the caches compare. */
|
|
197
|
+
static EMPTY = Object.freeze([]);
|
|
198
|
+
hidden = false;
|
|
199
|
+
constructor(component) {
|
|
200
|
+
this.component = component;
|
|
201
|
+
}
|
|
202
|
+
/** Whoever is in the slot right now. */
|
|
203
|
+
get child() {
|
|
204
|
+
return this.component;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Swap the occupant, keeping the slot itself in place.
|
|
208
|
+
*
|
|
209
|
+
* An extension replacing the footer used to remove one root child and append
|
|
210
|
+
* another, which both moved the footer to the end of the tree — behind the
|
|
211
|
+
* widgets meant to sit below it — and handed the root's per-child cache a
|
|
212
|
+
* changed child list every time. The slot is the stable root child; only what
|
|
213
|
+
* is inside it changes.
|
|
214
|
+
*/
|
|
215
|
+
setChild(component) {
|
|
216
|
+
if (this.component === component)
|
|
217
|
+
return false;
|
|
218
|
+
this.component = component;
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
get visible() {
|
|
222
|
+
return !this.hidden;
|
|
223
|
+
}
|
|
224
|
+
/** Returns whether this changed anything, so callers can skip a render. */
|
|
225
|
+
setVisible(visible) {
|
|
226
|
+
const hidden = !visible;
|
|
227
|
+
if (this.hidden === hidden)
|
|
228
|
+
return false;
|
|
229
|
+
this.hidden = hidden;
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
invalidate() {
|
|
233
|
+
this.child.invalidate?.();
|
|
234
|
+
}
|
|
235
|
+
render(width) {
|
|
236
|
+
if (this.hidden)
|
|
237
|
+
return Slot.EMPTY;
|
|
238
|
+
return this.child.render(width);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
122
241
|
/**
|
|
123
242
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
124
243
|
*/
|
|
@@ -165,6 +284,56 @@ export class TUI extends Container {
|
|
|
165
284
|
previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
|
166
285
|
fullRedrawCount = 0;
|
|
167
286
|
stopped = false;
|
|
287
|
+
/**
|
|
288
|
+
* The pinned viewport.
|
|
289
|
+
*
|
|
290
|
+
* ## What is wrong with letting the terminal do it
|
|
291
|
+
*
|
|
292
|
+
* This renderer keeps the entire transcript in its line buffer and writes it
|
|
293
|
+
* to the normal screen, so "scrolling" has always meant the terminal's own
|
|
294
|
+
* scrollback. That works exactly as long as the app does not repaint — and
|
|
295
|
+
* this one repaints the whole buffer whenever a line *above* the viewport
|
|
296
|
+
* changes, because a positional diff cannot address a row that has scrolled
|
|
297
|
+
* out of reach. The repaint is `\x1b[2J\x1b[H\x1b[3J` followed by the
|
|
298
|
+
* transcript again, and the `\x1b[3J` throws away the scrollback the reader
|
|
299
|
+
* was sitting in. From the reader's side the screen simply jumps to the
|
|
300
|
+
* bottom, for no reason they can see, at a moment they did not choose.
|
|
301
|
+
*
|
|
302
|
+
* ## What this does instead
|
|
303
|
+
*
|
|
304
|
+
* `scrollOffset` is the transcript row drawn at the top of the screen, and
|
|
305
|
+
* `null` means "follow the tail", which is the normal live behaviour and the
|
|
306
|
+
* path everything else in this file was written for. The moment it is a
|
|
307
|
+
* number the TUI switches to the alternate screen and paints a window of the
|
|
308
|
+
* buffer itself: a fixed grid, addressed row by row, with no scrollback for
|
|
309
|
+
* anything to fight over. New output still arrives and still lands in the
|
|
310
|
+
* buffer — it just does not move the window, which is the whole point. The
|
|
311
|
+
* indicator on the last row says how far down the transcript the window is,
|
|
312
|
+
* because a view that cannot move on its own needs to say where it stopped.
|
|
313
|
+
*
|
|
314
|
+
* Going back to live leaves the alternate screen, which restores the normal
|
|
315
|
+
* screen *and its scrollback* exactly as they were, and the next frame is an
|
|
316
|
+
* ordinary differential one that writes only what arrived while the reader
|
|
317
|
+
* was away — see `scrollToLive` for what has to be true for that to be safe.
|
|
318
|
+
* Not a clear-and-replay: on a long session that is a visible flash, a burst
|
|
319
|
+
* of output, and the loss of the scrollback that had just been handed back.
|
|
320
|
+
*/
|
|
321
|
+
scrollOffset = null;
|
|
322
|
+
/** Transcript length measured by the last pinned paint; what clamping uses. */
|
|
323
|
+
scrollTotalLines = 0;
|
|
324
|
+
scrollStatusFormatter = defaultScrollStatus;
|
|
325
|
+
/** The running search: its query, the rows it matched, and where in them. */
|
|
326
|
+
scrollSearch = null;
|
|
327
|
+
/**
|
|
328
|
+
* Whether the view may pin right now.
|
|
329
|
+
*
|
|
330
|
+
* The wheel is answered wherever it is turned, including with a picker on
|
|
331
|
+
* screen — and a picker that lost its arrow keys to a pinned view it did not
|
|
332
|
+
* know about would be far worse than a wheel that did nothing. The app sets
|
|
333
|
+
* this because only the app knows which of its surfaces is asking a question.
|
|
334
|
+
* Unset means always.
|
|
335
|
+
*/
|
|
336
|
+
canPinScroll;
|
|
168
337
|
// Overlay stack for modal components rendered on top of base content
|
|
169
338
|
focusOrderCounter = 0;
|
|
170
339
|
overlayStack = [];
|
|
@@ -178,6 +347,289 @@ export class TUI extends Container {
|
|
|
178
347
|
get fullRedraws() {
|
|
179
348
|
return this.fullRedrawCount;
|
|
180
349
|
}
|
|
350
|
+
// ── The pinned viewport ─────────────────────────────────────────────────
|
|
351
|
+
/** True while the view is pinned rather than following the tail. */
|
|
352
|
+
get scrollPinned() {
|
|
353
|
+
return this.scrollOffset !== null;
|
|
354
|
+
}
|
|
355
|
+
/** Where the pinned window sits, or null while live. */
|
|
356
|
+
getScrollPosition() {
|
|
357
|
+
if (this.scrollOffset === null)
|
|
358
|
+
return null;
|
|
359
|
+
return { top: this.scrollOffset, total: this.scrollTotalLines, viewHeight: this.scrollViewHeight() };
|
|
360
|
+
}
|
|
361
|
+
/** Let the app paint the indicator in its own theme. */
|
|
362
|
+
setScrollStatusFormatter(formatter) {
|
|
363
|
+
this.scrollStatusFormatter = formatter;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The screen rows a pinned window shows, the last one being the indicator.
|
|
367
|
+
*
|
|
368
|
+
* The indicator is not optional: a pinned view looks exactly like a live one
|
|
369
|
+
* that has gone quiet, and a reader who cannot tell the two apart will wait
|
|
370
|
+
* for output that is arriving perfectly well just out of sight.
|
|
371
|
+
*/
|
|
372
|
+
scrollViewHeight() {
|
|
373
|
+
return Math.max(1, this.terminal.rows - 1);
|
|
374
|
+
}
|
|
375
|
+
/** Rows available to scroll through — the live buffer while live, the
|
|
376
|
+
* measured one while pinned. */
|
|
377
|
+
transcriptLength() {
|
|
378
|
+
return this.scrollOffset === null ? this.previousLines.length : this.scrollTotalLines;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Move the view by `delta` rows; negative is towards the start.
|
|
382
|
+
*
|
|
383
|
+
* Returns whether anything moved, so a caller can let the key fall through
|
|
384
|
+
* to whatever else wants it when there is nothing to scroll.
|
|
385
|
+
*/
|
|
386
|
+
scrollByLines(delta) {
|
|
387
|
+
if (delta === 0)
|
|
388
|
+
return false;
|
|
389
|
+
// Scrolling down while already live is not "scroll to somewhere", it is a
|
|
390
|
+
// request for content that does not exist yet. Doing nothing is right, and
|
|
391
|
+
// cheap: treating it as a move would drop out of scroll mode and force a
|
|
392
|
+
// full repaint on every wheel notch at the bottom of the transcript.
|
|
393
|
+
if (this.scrollOffset === null && delta > 0)
|
|
394
|
+
return false;
|
|
395
|
+
const viewHeight = this.scrollViewHeight();
|
|
396
|
+
const maxOffset = Math.max(0, this.transcriptLength() - viewHeight);
|
|
397
|
+
if (maxOffset === 0)
|
|
398
|
+
return false;
|
|
399
|
+
const next = (this.scrollOffset ?? maxOffset) + delta;
|
|
400
|
+
// Reaching the end is how the pin lets go: the reader has caught up, so
|
|
401
|
+
// give them the live screen back rather than a pinned view of the tail
|
|
402
|
+
// that silently stops following.
|
|
403
|
+
if (next >= maxOffset)
|
|
404
|
+
return this.scrollToLive();
|
|
405
|
+
this.setScrollOffset(next);
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Move by pages, keeping two rows of overlap.
|
|
410
|
+
*
|
|
411
|
+
* A page that moves a full screen leaves nothing in common between before
|
|
412
|
+
* and after, and the reader has to find their place again on every press.
|
|
413
|
+
* The two kept rows are what makes the jump readable.
|
|
414
|
+
*/
|
|
415
|
+
scrollByPages(delta) {
|
|
416
|
+
const page = Math.max(1, this.scrollViewHeight() - 2);
|
|
417
|
+
return this.scrollByLines(delta * page);
|
|
418
|
+
}
|
|
419
|
+
/** Pin the view to the very start of the transcript. */
|
|
420
|
+
scrollToTop() {
|
|
421
|
+
const maxOffset = Math.max(0, this.transcriptLength() - this.scrollViewHeight());
|
|
422
|
+
if (maxOffset === 0)
|
|
423
|
+
return false;
|
|
424
|
+
if (this.scrollOffset === 0)
|
|
425
|
+
return false;
|
|
426
|
+
this.setScrollOffset(0);
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
/** Release the pin and follow the tail again. */
|
|
430
|
+
scrollToLive() {
|
|
431
|
+
if (this.scrollOffset === null)
|
|
432
|
+
return false;
|
|
433
|
+
this.scrollOffset = null;
|
|
434
|
+
this.scrollSearch = null;
|
|
435
|
+
this.terminal.setAlternateScreen(false);
|
|
436
|
+
// `?1049l` restores the normal screen, its scrollback and the cursor
|
|
437
|
+
// exactly as they were at `?1049h`, and the snapshot taken on the way in
|
|
438
|
+
// says what that screen holds — so the next frame can be an ordinary
|
|
439
|
+
// differential one that writes only what arrived while we were reading.
|
|
440
|
+
// Dropping the flat cache is what makes it honest: the cache has been
|
|
441
|
+
// patched on every pinned frame, and a patch report describing rows that
|
|
442
|
+
// were painted to the *alternate* screen would leave the diff addressing
|
|
443
|
+
// the wrong ones.
|
|
444
|
+
this.flatCache = undefined;
|
|
445
|
+
this.lastCursorPos = undefined;
|
|
446
|
+
this.requestRender();
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
// ── Searching the pinned view ───────────────────────────────────────────
|
|
450
|
+
/**
|
|
451
|
+
* Find rows containing `query`, and pin the view to the nearest one above.
|
|
452
|
+
*
|
|
453
|
+
* Searching *backwards* first is the `ctrl+r` convention and it is the right
|
|
454
|
+
* one here: what you are looking for in a session is nearly always behind
|
|
455
|
+
* you, and the most recent occurrence is nearly always the one you meant.
|
|
456
|
+
*
|
|
457
|
+
* Matching is over the visible text, so it finds what the screen shows
|
|
458
|
+
* rather than the escape sequences underneath it — a query containing a
|
|
459
|
+
* colour code is not something anyone ever means.
|
|
460
|
+
*
|
|
461
|
+
* Returns how many rows matched.
|
|
462
|
+
*/
|
|
463
|
+
setScrollSearch(query, options = {}) {
|
|
464
|
+
if (query.length === 0) {
|
|
465
|
+
this.scrollSearch = { query, matches: [], index: -1, measuredAt: -1, typing: options.typing ?? true };
|
|
466
|
+
this.requestRender();
|
|
467
|
+
this.expediteRender();
|
|
468
|
+
return 0;
|
|
469
|
+
}
|
|
470
|
+
const from = this.scrollOffset ?? Math.max(0, this.transcriptLength() - 1);
|
|
471
|
+
const matches = this.findScrollMatches(query);
|
|
472
|
+
// The nearest match at or above where the eye is, else wrap to the last.
|
|
473
|
+
let index = -1;
|
|
474
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
475
|
+
if (matches[i] <= from) {
|
|
476
|
+
index = i;
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (index === -1 && matches.length > 0)
|
|
481
|
+
index = matches.length - 1;
|
|
482
|
+
this.scrollSearch = {
|
|
483
|
+
query,
|
|
484
|
+
matches,
|
|
485
|
+
index,
|
|
486
|
+
measuredAt: this.flatLines?.length ?? this.previousLines.length,
|
|
487
|
+
typing: options.typing ?? true,
|
|
488
|
+
};
|
|
489
|
+
if (index >= 0)
|
|
490
|
+
this.scrollToRow(matches[index]);
|
|
491
|
+
this.requestRender();
|
|
492
|
+
this.expediteRender();
|
|
493
|
+
return matches.length;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Step to the next match, `-1` being further back through the session.
|
|
497
|
+
*
|
|
498
|
+
* Wraps, because a search that stops dead at the last match makes you
|
|
499
|
+
* retype it to get back to the first.
|
|
500
|
+
*/
|
|
501
|
+
scrollSearchStep(direction) {
|
|
502
|
+
const search = this.scrollSearch;
|
|
503
|
+
if (!search || search.matches.length === 0)
|
|
504
|
+
return false;
|
|
505
|
+
const next = (search.index + direction + search.matches.length) % search.matches.length;
|
|
506
|
+
search.index = next;
|
|
507
|
+
search.typing = false;
|
|
508
|
+
this.scrollToRow(search.matches[next]);
|
|
509
|
+
this.requestRender();
|
|
510
|
+
this.expediteRender();
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
/** Stop typing the query but keep the matches, so n/N can step them. */
|
|
514
|
+
commitScrollSearch() {
|
|
515
|
+
if (!this.scrollSearch)
|
|
516
|
+
return;
|
|
517
|
+
this.scrollSearch.typing = false;
|
|
518
|
+
this.requestRender();
|
|
519
|
+
this.expediteRender();
|
|
520
|
+
}
|
|
521
|
+
/** Drop the search, leaving the view where it is. */
|
|
522
|
+
clearScrollSearch() {
|
|
523
|
+
if (!this.scrollSearch)
|
|
524
|
+
return;
|
|
525
|
+
this.scrollSearch = null;
|
|
526
|
+
this.requestRender();
|
|
527
|
+
this.expediteRender();
|
|
528
|
+
}
|
|
529
|
+
get scrollSearchActive() {
|
|
530
|
+
return this.scrollSearch !== null;
|
|
531
|
+
}
|
|
532
|
+
/** The query being searched for, or "" when there is no search. */
|
|
533
|
+
get scrollSearchQuery() {
|
|
534
|
+
return this.scrollSearch?.query ?? "";
|
|
535
|
+
}
|
|
536
|
+
scrollSearchStatus() {
|
|
537
|
+
const search = this.scrollSearch;
|
|
538
|
+
if (!search)
|
|
539
|
+
return undefined;
|
|
540
|
+
return {
|
|
541
|
+
query: search.query,
|
|
542
|
+
count: search.matches.length,
|
|
543
|
+
index: search.index >= 0 ? search.index + 1 : 0,
|
|
544
|
+
typing: search.typing,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Rows whose visible text contains `query`, case-insensitively.
|
|
549
|
+
*
|
|
550
|
+
* One pass over the buffer, run when the query changes rather than per
|
|
551
|
+
* frame. The results are re-measured if the transcript has grown since —
|
|
552
|
+
* rare while someone is reading, and wrong in a way people notice if skipped.
|
|
553
|
+
*/
|
|
554
|
+
findScrollMatches(query) {
|
|
555
|
+
const lines = this.flatLines ?? this.previousLines;
|
|
556
|
+
const needle = query.toLowerCase();
|
|
557
|
+
const matches = [];
|
|
558
|
+
for (let row = 0; row < lines.length; row++) {
|
|
559
|
+
const line = lines[row];
|
|
560
|
+
if (line.length === 0)
|
|
561
|
+
continue;
|
|
562
|
+
if (stripVTControlCharacters(line).toLowerCase().includes(needle))
|
|
563
|
+
matches.push(row);
|
|
564
|
+
}
|
|
565
|
+
return matches;
|
|
566
|
+
}
|
|
567
|
+
/** Re-run the search if the buffer grew under it. */
|
|
568
|
+
refreshScrollSearch(total) {
|
|
569
|
+
const search = this.scrollSearch;
|
|
570
|
+
if (!search || search.query.length === 0 || search.measuredAt === total)
|
|
571
|
+
return;
|
|
572
|
+
search.matches = this.findScrollMatches(search.query);
|
|
573
|
+
search.measuredAt = total;
|
|
574
|
+
if (search.index >= search.matches.length)
|
|
575
|
+
search.index = search.matches.length - 1;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Mark the query where it appears in a row about to be painted.
|
|
579
|
+
*
|
|
580
|
+
* Done at paint time, over the handful of rows on screen, rather than stored
|
|
581
|
+
* per match — highlighting the whole buffer to show a screenful would be the
|
|
582
|
+
* same work multiplied by the session's length.
|
|
583
|
+
*
|
|
584
|
+
* The row is sliced by display column so the styling already in it survives:
|
|
585
|
+
* a match inside a coloured span keeps its colour and gains the marker.
|
|
586
|
+
*/
|
|
587
|
+
highlightScrollMatches(line, query) {
|
|
588
|
+
const plain = stripVTControlCharacters(line);
|
|
589
|
+
const needle = query.toLowerCase();
|
|
590
|
+
const haystack = plain.toLowerCase();
|
|
591
|
+
let at = haystack.indexOf(needle);
|
|
592
|
+
if (at === -1)
|
|
593
|
+
return line;
|
|
594
|
+
let out = "";
|
|
595
|
+
let cursor = 0;
|
|
596
|
+
while (at !== -1) {
|
|
597
|
+
const startCol = visibleWidth(plain.slice(0, at));
|
|
598
|
+
const endCol = startCol + visibleWidth(plain.slice(at, at + query.length));
|
|
599
|
+
const fromCol = visibleWidth(plain.slice(0, cursor));
|
|
600
|
+
out += sliceByColumn(line, fromCol, startCol - fromCol);
|
|
601
|
+
// 27 turns reverse off on its own, so whatever colour the row was
|
|
602
|
+
// wearing underneath the match carries on afterwards.
|
|
603
|
+
out += `\x1b[7m${sliceByColumn(line, startCol, endCol - startCol)}\x1b[27m`;
|
|
604
|
+
cursor = at + query.length;
|
|
605
|
+
at = haystack.indexOf(needle, cursor);
|
|
606
|
+
}
|
|
607
|
+
const tailCol = visibleWidth(plain.slice(0, cursor));
|
|
608
|
+
out += sliceByColumn(line, tailCol, Number.MAX_SAFE_INTEGER - tailCol);
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
setScrollOffset(offset) {
|
|
612
|
+
const entering = this.scrollOffset === null;
|
|
613
|
+
// Only entry is gated. A view that is already pinned keeps responding, so
|
|
614
|
+
// a surface opening underneath cannot strand the reader somewhere they
|
|
615
|
+
// have no key to leave.
|
|
616
|
+
if (entering && this.canPinScroll && !this.canPinScroll())
|
|
617
|
+
return;
|
|
618
|
+
this.scrollOffset = Math.max(0, offset);
|
|
619
|
+
if (entering) {
|
|
620
|
+
// What the normal screen is left showing, frozen. Without the copy this
|
|
621
|
+
// stays the same array the patching render() mutates in place, so on the
|
|
622
|
+
// way back out it would be diffed against itself and report that nothing
|
|
623
|
+
// arrived while the reader was away.
|
|
624
|
+
this.previousLines = this.previousLines.slice();
|
|
625
|
+
this.terminal.setAlternateScreen(true);
|
|
626
|
+
this.terminal.hideCursor();
|
|
627
|
+
}
|
|
628
|
+
this.requestRender();
|
|
629
|
+
// Scrolling is a direct manipulation: the view has to move under the
|
|
630
|
+
// gesture, not one animation frame behind it.
|
|
631
|
+
this.expediteRender();
|
|
632
|
+
}
|
|
181
633
|
getShowHardwareCursor() {
|
|
182
634
|
return this.showHardwareCursor;
|
|
183
635
|
}
|
|
@@ -201,6 +653,48 @@ export class TUI extends Container {
|
|
|
201
653
|
setClearOnShrink(enabled) {
|
|
202
654
|
this.clearOnShrink = enabled;
|
|
203
655
|
}
|
|
656
|
+
/** The component keystrokes are currently going to. */
|
|
657
|
+
get focused() {
|
|
658
|
+
return this.focusedComponent;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* The root's own child offsets, which the flat cache already tracks.
|
|
662
|
+
*
|
|
663
|
+
* The base implementation reads `Container.renderMemo`, which the root does
|
|
664
|
+
* not keep — it has `flatCache` instead, holding exactly this. Falling
|
|
665
|
+
* through to the base would quietly return undefined forever.
|
|
666
|
+
*/
|
|
667
|
+
childRowOffsets(width) {
|
|
668
|
+
const cache = this.flatCache;
|
|
669
|
+
if (!cache || cache.width !== width)
|
|
670
|
+
return undefined;
|
|
671
|
+
return cache.offsets;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Pin the view so `row` is on screen, without demanding it be at the top.
|
|
675
|
+
*
|
|
676
|
+
* A jump that always parks its target on the first row throws away whatever
|
|
677
|
+
* led up to it, and what led up to a message is most of what makes it
|
|
678
|
+
* readable. A row already comfortably in view is left where it is, so
|
|
679
|
+
* stepping through nearby landmarks does not make the screen lurch for each
|
|
680
|
+
* one.
|
|
681
|
+
*/
|
|
682
|
+
scrollToRow(row, options = {}) {
|
|
683
|
+
const viewHeight = this.scrollViewHeight();
|
|
684
|
+
const maxOffset = Math.max(0, this.transcriptLength() - viewHeight);
|
|
685
|
+
if (maxOffset === 0)
|
|
686
|
+
return false;
|
|
687
|
+
const context = options.context ?? Math.min(3, Math.max(0, viewHeight - 1));
|
|
688
|
+
const target = Math.max(0, Math.min(row - context, maxOffset));
|
|
689
|
+
if (this.scrollOffset !== null) {
|
|
690
|
+
const top = this.scrollOffset;
|
|
691
|
+
// Already visible with room to read above it: leave it alone.
|
|
692
|
+
if (row >= top + context && row < top + viewHeight)
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
this.setScrollOffset(target);
|
|
696
|
+
return this.scrollOffset === target;
|
|
697
|
+
}
|
|
204
698
|
setFocus(component) {
|
|
205
699
|
// Clear focused flag on old component
|
|
206
700
|
if (isFocusable(this.focusedComponent)) {
|
|
@@ -357,6 +851,12 @@ export class TUI extends Container {
|
|
|
357
851
|
this.terminal.write("\x1b[16t");
|
|
358
852
|
}
|
|
359
853
|
stop() {
|
|
854
|
+
// Off the alternate screen before the exit bookkeeping below, which moves
|
|
855
|
+
// the cursor relative to content that lives on the normal screen.
|
|
856
|
+
if (this.scrollOffset !== null) {
|
|
857
|
+
this.scrollOffset = null;
|
|
858
|
+
this.terminal.setAlternateScreen(false);
|
|
859
|
+
}
|
|
360
860
|
this.stopped = true;
|
|
361
861
|
if (this.renderTimer) {
|
|
362
862
|
clearTimeout(this.renderTimer);
|
|
@@ -426,6 +926,14 @@ export class TUI extends Container {
|
|
|
426
926
|
}, delay);
|
|
427
927
|
}
|
|
428
928
|
handleInput(data) {
|
|
929
|
+
// Ahead of the listeners: a mouse report that reaches a text field is
|
|
930
|
+
// typed into it, and a paste-detecting listener has no reason to see one.
|
|
931
|
+
if (this.terminal.mouseReporting) {
|
|
932
|
+
const remaining = this.consumeMouseReports(data);
|
|
933
|
+
if (remaining === null)
|
|
934
|
+
return;
|
|
935
|
+
data = remaining;
|
|
936
|
+
}
|
|
429
937
|
if (this.inputListeners.size > 0) {
|
|
430
938
|
let current = data;
|
|
431
939
|
for (const listener of this.inputListeners) {
|
|
@@ -480,6 +988,56 @@ export class TUI extends Container {
|
|
|
480
988
|
this.expediteRender();
|
|
481
989
|
}
|
|
482
990
|
}
|
|
991
|
+
/**
|
|
992
|
+
* Act on every mouse report in `data` and return what is left of it.
|
|
993
|
+
*
|
|
994
|
+
* Returns null when the chunk was nothing but reports. Reports arrive
|
|
995
|
+
* coalesced — a flick of the wheel delivers a run of them in one read, and a
|
|
996
|
+
* keystroke pressed during the flick rides along behind — so they are peeled
|
|
997
|
+
* off one at a time instead of the chunk being classified as a whole.
|
|
998
|
+
*/
|
|
999
|
+
consumeMouseReports(data) {
|
|
1000
|
+
// Neither introducer present is the overwhelmingly common case (every
|
|
1001
|
+
// ordinary keystroke), and it costs one scan of a very short string.
|
|
1002
|
+
if (!data.includes("\x1b[<") && !data.includes("\x1b[M"))
|
|
1003
|
+
return data;
|
|
1004
|
+
let rest = data;
|
|
1005
|
+
let out = "";
|
|
1006
|
+
let sawReport = false;
|
|
1007
|
+
while (rest.length > 0) {
|
|
1008
|
+
const length = mouseSequenceLength(rest);
|
|
1009
|
+
if (length === 0) {
|
|
1010
|
+
out += rest[0];
|
|
1011
|
+
rest = rest.slice(1);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const event = parseMouseEvent(rest.slice(0, length));
|
|
1015
|
+
if (event)
|
|
1016
|
+
this.handleMouseEvent(event);
|
|
1017
|
+
sawReport = true;
|
|
1018
|
+
rest = rest.slice(length);
|
|
1019
|
+
}
|
|
1020
|
+
if (!sawReport)
|
|
1021
|
+
return data;
|
|
1022
|
+
return out.length > 0 ? out : null;
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* What the mouse does.
|
|
1026
|
+
*
|
|
1027
|
+
* Only the wheel is acted on. Clicks are swallowed rather than handled:
|
|
1028
|
+
* reporting is on for the wheel's sake, and a click that fell through to the
|
|
1029
|
+
* focused component would arrive as the raw report text in whatever field
|
|
1030
|
+
* has focus.
|
|
1031
|
+
*/
|
|
1032
|
+
handleMouseEvent(event) {
|
|
1033
|
+
if (event.kind === "wheelUp") {
|
|
1034
|
+
this.scrollByLines(-WHEEL_LINES);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (event.kind === "wheelDown") {
|
|
1038
|
+
this.scrollByLines(WHEEL_LINES);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
483
1041
|
/** Run a requested render now, bypassing the coalescing delay. Used for
|
|
484
1042
|
* input-driven frames where echo latency matters more than batching. */
|
|
485
1043
|
expediteRender() {
|
|
@@ -931,9 +1489,92 @@ export class TUI extends Container {
|
|
|
931
1489
|
this.lastPatch = low === Infinity && high === -1 ? null : { low: low === Infinity ? 0 : low, high, prevLength };
|
|
932
1490
|
return flat;
|
|
933
1491
|
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Paint the pinned window onto the alternate screen.
|
|
1494
|
+
*
|
|
1495
|
+
* Deliberately not differential. The alternate screen is `rows` tall and
|
|
1496
|
+
* nothing else writes to it, so a whole frame is at most a screenful of
|
|
1497
|
+
* cells inside one synchronized-output pair — cheaper to emit than the
|
|
1498
|
+
* bookkeeping a diff would need, and with nothing to get out of step with.
|
|
1499
|
+
* The differential renderer's state is left exactly as the last live frame
|
|
1500
|
+
* left it, because `scrollToLive` throws it away rather than resuming from it.
|
|
1501
|
+
*/
|
|
1502
|
+
renderScrollView() {
|
|
1503
|
+
const width = this.terminal.columns;
|
|
1504
|
+
const height = this.terminal.rows;
|
|
1505
|
+
let lines = this.render(width);
|
|
1506
|
+
// A patch computed while pinned describes rows nothing painted to the
|
|
1507
|
+
// normal screen, so the live path must never be handed it.
|
|
1508
|
+
this.lastPatch = "full";
|
|
1509
|
+
if (this.overlayStack.length > 0) {
|
|
1510
|
+
lines = this.compositeOverlays(lines, width, height);
|
|
1511
|
+
}
|
|
1512
|
+
const viewHeight = this.scrollViewHeight();
|
|
1513
|
+
this.scrollTotalLines = lines.length;
|
|
1514
|
+
const maxOffset = Math.max(0, lines.length - viewHeight);
|
|
1515
|
+
// The transcript can shrink under a pinned view — a pane closing, a tool
|
|
1516
|
+
// block collapsing — so the offset is re-clamped every frame rather than
|
|
1517
|
+
// only where it is set.
|
|
1518
|
+
const top = Math.min(Math.max(0, this.scrollOffset ?? 0), maxOffset);
|
|
1519
|
+
this.scrollOffset = top;
|
|
1520
|
+
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
1521
|
+
buffer += HIDE_CURSOR;
|
|
1522
|
+
// Autowrap off for the paint: a full-width row would otherwise wrap into
|
|
1523
|
+
// the row below it and shift the rest of the window down by one.
|
|
1524
|
+
buffer += "\x1b[?7l";
|
|
1525
|
+
this.refreshScrollSearch(lines.length);
|
|
1526
|
+
const query = this.scrollSearch?.query ?? "";
|
|
1527
|
+
for (let row = 0; row < viewHeight; row++) {
|
|
1528
|
+
buffer += `\x1b[${row + 1};1H\x1b[2K`;
|
|
1529
|
+
const line = lines[top + row];
|
|
1530
|
+
if (line !== undefined)
|
|
1531
|
+
buffer += this.emitScrollLine(line, query);
|
|
1532
|
+
}
|
|
1533
|
+
buffer += `\x1b[${height};1H\x1b[2K`;
|
|
1534
|
+
buffer += this.scrollStatusFormatter({
|
|
1535
|
+
top: top + 1,
|
|
1536
|
+
bottom: Math.min(top + viewHeight, lines.length),
|
|
1537
|
+
total: lines.length,
|
|
1538
|
+
viewHeight,
|
|
1539
|
+
atTop: top === 0,
|
|
1540
|
+
atBottom: top >= maxOffset,
|
|
1541
|
+
width,
|
|
1542
|
+
search: this.scrollSearchStatus(),
|
|
1543
|
+
});
|
|
1544
|
+
buffer += "\x1b[?7h";
|
|
1545
|
+
buffer += "\x1b[?2026l"; // End synchronized output
|
|
1546
|
+
this.terminal.write(buffer);
|
|
1547
|
+
// `previousWidth` / `previousHeight` are deliberately left describing the
|
|
1548
|
+
// last *live* frame. If the terminal was resized while pinned they will
|
|
1549
|
+
// disagree with the real size on the way out, and the live path will take
|
|
1550
|
+
// its full-redraw branch — which is exactly right, because the normal
|
|
1551
|
+
// screen `?1049l` restored was drawn at the old size.
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* One transcript row, ready for the pinned window.
|
|
1555
|
+
*
|
|
1556
|
+
* Images are named rather than drawn. A kitty or iTerm image is placed by
|
|
1557
|
+
* the cursor and sized in pixels, so the same escape replayed at a different
|
|
1558
|
+
* screen row lands somewhere the window did not ask for and survives the
|
|
1559
|
+
* frame that was supposed to replace it — a smear across the view that no
|
|
1560
|
+
* later repaint can clear.
|
|
1561
|
+
*/
|
|
1562
|
+
emitScrollLine(line, query = "") {
|
|
1563
|
+
if (isImageLine(line))
|
|
1564
|
+
return "\x1b[2m[image]\x1b[0m";
|
|
1565
|
+
const marker = line.indexOf(CURSOR_MARKER);
|
|
1566
|
+
let text = marker === -1 ? line : line.slice(0, marker) + line.slice(marker + CURSOR_MARKER.length);
|
|
1567
|
+
if (query.length > 0)
|
|
1568
|
+
text = this.highlightScrollMatches(text, query);
|
|
1569
|
+
return normalizeTerminalOutput(text) + TUI.SEGMENT_RESET;
|
|
1570
|
+
}
|
|
934
1571
|
doRender() {
|
|
935
1572
|
if (this.stopped)
|
|
936
1573
|
return;
|
|
1574
|
+
if (this.scrollOffset !== null) {
|
|
1575
|
+
this.renderScrollView();
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
937
1578
|
const width = this.terminal.columns;
|
|
938
1579
|
const height = this.terminal.rows;
|
|
939
1580
|
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|