@kolisachint/hoocode-tui 0.5.72 → 0.5.74
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/box.d.ts +12 -5
- package/dist/components/box.d.ts.map +1 -1
- package/dist/components/box.js +28 -20
- package/dist/components/box.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tui.d.ts +84 -36
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +244 -52
- package/dist/tui.js.map +1 -1
- package/dist/utils.d.ts +13 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +40 -0
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/dist/tui.js
CHANGED
|
@@ -8,8 +8,8 @@ import { performance } from "node:perf_hooks";
|
|
|
8
8
|
import { stripVTControlCharacters } from "node:util";
|
|
9
9
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
10
10
|
import { mouseSequenceLength, parseMouseEvent } from "./mouse.js";
|
|
11
|
-
import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
12
|
-
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, truncateToWidth, visibleWidth, } from "./utils.js";
|
|
11
|
+
import { allocateImageId, deleteKittyImage, getCapabilities, isImageLine, setCellDimensions, } from "./terminal-image.js";
|
|
12
|
+
import { extractSegments, hyperlinkAt, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, truncateToWidth, visibleWidth, } from "./utils.js";
|
|
13
13
|
const KITTY_SEQUENCE_PREFIX = "\x1b_G";
|
|
14
14
|
function extractKittyImageIds(line) {
|
|
15
15
|
const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
|
|
@@ -31,6 +31,39 @@ function extractKittyImageIds(line) {
|
|
|
31
31
|
}
|
|
32
32
|
return [];
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Transmit the same image under a different id.
|
|
36
|
+
*
|
|
37
|
+
* Only the first chunk of a chunked transmission carries the parameter list,
|
|
38
|
+
* and that is the one `extractKittyImageIds` reads, so rewriting it is enough
|
|
39
|
+
* to make the whole sequence a second, independently deletable copy.
|
|
40
|
+
*/
|
|
41
|
+
function retagKittyImageId(line, id) {
|
|
42
|
+
const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
|
|
43
|
+
if (sequenceStart === -1)
|
|
44
|
+
return null;
|
|
45
|
+
const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
|
|
46
|
+
const paramsEnd = line.indexOf(";", paramsStart);
|
|
47
|
+
if (paramsEnd === -1)
|
|
48
|
+
return null;
|
|
49
|
+
const params = line.slice(paramsStart, paramsEnd);
|
|
50
|
+
const retagged = params.replace(/(^|,)i=\d+/, `$1i=${id}`);
|
|
51
|
+
if (retagged === params)
|
|
52
|
+
return null;
|
|
53
|
+
return line.slice(0, paramsStart) + retagged + line.slice(paramsEnd);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* How far above its own row an image line's picture reaches.
|
|
57
|
+
*
|
|
58
|
+
* `Image` renders an n-row picture as n-1 blank lines and one line that moves
|
|
59
|
+
* the cursor back up and draws, so the leading `CSI <n> A` is the whole block's
|
|
60
|
+
* height minus one — and the top edge of the picture is that many rows above
|
|
61
|
+
* wherever the line itself is painted.
|
|
62
|
+
*/
|
|
63
|
+
function imageRowOffset(line) {
|
|
64
|
+
const match = /^\x1b\[(\d+)A/.exec(line);
|
|
65
|
+
return match ? Number(match[1]) : 0;
|
|
66
|
+
}
|
|
34
67
|
/** Type guard to check if a component implements Focusable */
|
|
35
68
|
export function isFocusable(component) {
|
|
36
69
|
return component !== null && "focused" in component;
|
|
@@ -265,6 +298,20 @@ export class TUI extends Container {
|
|
|
265
298
|
* per-frame full-buffer scan (collectKittyImageIds) is skipped entirely —
|
|
266
299
|
* the common case for a pure-text session. */
|
|
267
300
|
sawImageLine = false;
|
|
301
|
+
/**
|
|
302
|
+
* Live kitty image id -> the id the pinned window transmits its own copy
|
|
303
|
+
* under, and the copies currently placed on the alternate screen.
|
|
304
|
+
*
|
|
305
|
+
* The pinned window repaints whole, so a placement from the previous scroll
|
|
306
|
+
* position has to be deleted or it hangs over the new one. Deleting a kitty
|
|
307
|
+
* image deletes *every* placement of it, though, including the one on the
|
|
308
|
+
* normal screen — which the differential frame on the way back out has no
|
|
309
|
+
* reason to repaint, so the picture would simply be gone. The window
|
|
310
|
+
* therefore transmits a second copy under an id of its own and only ever
|
|
311
|
+
* deletes that one.
|
|
312
|
+
*/
|
|
313
|
+
scrollImageIds = new Map();
|
|
314
|
+
scrollPlacedImages = new Set();
|
|
268
315
|
static EMPTY_KITTY_IDS = new Set();
|
|
269
316
|
previousWidth = 0;
|
|
270
317
|
previousHeight = 0;
|
|
@@ -313,6 +360,18 @@ export class TUI extends Container {
|
|
|
313
360
|
* any embedder outside the app rely on.
|
|
314
361
|
*/
|
|
315
362
|
flexSpacer;
|
|
363
|
+
/** Where the left button went down, for telling a click from a drag. */
|
|
364
|
+
pressedCell;
|
|
365
|
+
/** What the pinned window painted last, so a click on it can be placed. */
|
|
366
|
+
scrollViewLines;
|
|
367
|
+
/**
|
|
368
|
+
* Open the URL behind a clicked hyperlink. Unset, clicks do nothing.
|
|
369
|
+
*
|
|
370
|
+
* The TUI resolves *which* link was clicked and deliberately stops there:
|
|
371
|
+
* opening a URL is spawning a process, which is the embedder's policy to
|
|
372
|
+
* make, not a rendering library's.
|
|
373
|
+
*/
|
|
374
|
+
onHyperlink;
|
|
316
375
|
/**
|
|
317
376
|
* The pinned viewport.
|
|
318
377
|
*
|
|
@@ -394,9 +453,10 @@ export class TUI extends Container {
|
|
|
394
453
|
/**
|
|
395
454
|
* Nominate the child that absorbs the leftover rows (see `flexSpacer`).
|
|
396
455
|
*
|
|
397
|
-
* It must already be a child of the root, and it
|
|
398
|
-
*
|
|
399
|
-
* bottom
|
|
456
|
+
* It must already be a child of the root, and it belongs at the *top* of the
|
|
457
|
+
* tree: everything after it is pushed to the foot of the screen, so the app
|
|
458
|
+
* reads bottom-up the way a terminal does — the newest row next to the
|
|
459
|
+
* prompt, and whatever room is left over above the first thing drawn.
|
|
400
460
|
*/
|
|
401
461
|
setFlexSpacer(spacer) {
|
|
402
462
|
this.flexSpacer = spacer;
|
|
@@ -409,45 +469,26 @@ export class TUI extends Container {
|
|
|
409
469
|
* frame that answers it is always the second one. Both passes are cheap
|
|
410
470
|
* after the first: every other child returns its memoized array untouched.
|
|
411
471
|
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
472
|
+
* The whole rule is one line, and it is the same one in both directions:
|
|
473
|
+
* the buffer is never shorter than the screen. A session that fits gets the
|
|
474
|
+
* difference as blank rows, which — with the spacer at the top of the tree —
|
|
475
|
+
* land *above* the first thing drawn, so the transcript always ends against
|
|
476
|
+
* the prompt. A session too long to fit gets nothing, because the terminal's
|
|
477
|
+
* own scroll is already holding the last row on the last row.
|
|
416
478
|
*
|
|
417
|
-
*
|
|
418
|
-
* the prompt on the floor
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
* and only the terminal can move them. That is a picker closing, a
|
|
424
|
-
* notification fading, a task ledger emptying — the prompt stranded
|
|
425
|
-
* mid-screen with a band of blank rows under it, and it stays stranded
|
|
426
|
-
* until enough output arrives to push it back down.
|
|
427
|
-
*
|
|
428
|
-
* So the fill takes what the shrinking content gave up, which keeps the
|
|
429
|
-
* buffer the length it already was and the last row where it already is.
|
|
430
|
-
* The blank band ends up *above* the chrome instead of below it, where it
|
|
431
|
-
* reads as room rather than as a layout that came apart, and the next
|
|
432
|
-
* output to arrive lands in it rather than scrolling the screen. Capped at
|
|
433
|
-
* a screenful: a fill longer than the screen is rows nobody can see, and it
|
|
434
|
-
* is given up altogether on the frames that repaint the whole screen anyway.
|
|
479
|
+
* It deliberately does not bank rows a shrinking frame gave up. That kept
|
|
480
|
+
* the prompt on the floor, but it paid for it with a band of blank rows
|
|
481
|
+
* between the conversation and the prompt — a screenful of it when a view
|
|
482
|
+
* dial folded the transcript — which is the thing the fill exists to avoid.
|
|
483
|
+
* A buffer that has to move back over the screen is repainted instead; see
|
|
484
|
+
* the window-repaint branch in `doRender`.
|
|
435
485
|
*/
|
|
436
|
-
fitFlexSpacer(lines, height
|
|
486
|
+
fitFlexSpacer(lines, height) {
|
|
437
487
|
const spacer = this.flexSpacer;
|
|
438
488
|
if (!spacer)
|
|
439
489
|
return false;
|
|
440
490
|
const content = lines.length - spacer.currentHeight;
|
|
441
|
-
|
|
442
|
-
return spacer.setHeight(height - content);
|
|
443
|
-
// A frame that is about to be repainted from the top of a cleared screen
|
|
444
|
-
// has no floor to hold: the resize already threw the old screen away, and
|
|
445
|
-
// holding its length would only bank a band of blank rows into the middle
|
|
446
|
-
// of the new one.
|
|
447
|
-
if (repaint)
|
|
448
|
-
return spacer.setHeight(0);
|
|
449
|
-
const held = Math.min(this.previousLines.length - content, height);
|
|
450
|
-
return spacer.setHeight(Math.max(0, held));
|
|
491
|
+
return spacer.setHeight(Math.max(0, height - content));
|
|
451
492
|
}
|
|
452
493
|
/**
|
|
453
494
|
* The screen rows a pinned window shows, the last one being the indicator.
|
|
@@ -523,6 +564,7 @@ export class TUI extends Container {
|
|
|
523
564
|
return false;
|
|
524
565
|
this.scrollOffset = null;
|
|
525
566
|
this.scrollSearch = null;
|
|
567
|
+
this.releaseScrollImages();
|
|
526
568
|
this.terminal.setAlternateScreen(false);
|
|
527
569
|
// `?1049l` restores the normal screen, its scrollback and the cursor
|
|
528
570
|
// exactly as they were at `?1049h`, and the snapshot taken on the way in
|
|
@@ -534,6 +576,7 @@ export class TUI extends Container {
|
|
|
534
576
|
// the wrong ones.
|
|
535
577
|
this.flatCache = undefined;
|
|
536
578
|
this.lastCursorPos = undefined;
|
|
579
|
+
this.scrollViewLines = undefined;
|
|
537
580
|
this.requestRender();
|
|
538
581
|
return true;
|
|
539
582
|
}
|
|
@@ -946,6 +989,7 @@ export class TUI extends Container {
|
|
|
946
989
|
// the cursor relative to content that lives on the normal screen.
|
|
947
990
|
if (this.scrollOffset !== null) {
|
|
948
991
|
this.scrollOffset = null;
|
|
992
|
+
this.releaseScrollImages();
|
|
949
993
|
this.terminal.setAlternateScreen(false);
|
|
950
994
|
}
|
|
951
995
|
this.stopped = true;
|
|
@@ -1113,12 +1157,20 @@ export class TUI extends Container {
|
|
|
1113
1157
|
return out.length > 0 ? out : null;
|
|
1114
1158
|
}
|
|
1115
1159
|
/**
|
|
1116
|
-
* What the mouse does.
|
|
1160
|
+
* What the mouse does: the wheel scrolls, and a click opens a link.
|
|
1161
|
+
*
|
|
1162
|
+
* The click is here because capturing the mouse took it away. A terminal
|
|
1163
|
+
* resolves a click on an OSC 8 hyperlink itself right up until an app turns
|
|
1164
|
+
* reporting on, at which point the report comes to the app and the link stops
|
|
1165
|
+
* working — so the app owes the user an answer. It is the same answer the
|
|
1166
|
+
* terminal would have given: the URL under the pointer, handed to whoever set
|
|
1167
|
+
* `onHyperlink`. Anything else is still swallowed, because a click that fell
|
|
1168
|
+
* through to the focused component would arrive as raw report text typed into
|
|
1169
|
+
* whatever field has focus.
|
|
1117
1170
|
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
* has focus.
|
|
1171
|
+
* Press and release both have to land on the same cell. A drag that happens
|
|
1172
|
+
* to end on a link is someone selecting text, not someone asking for a
|
|
1173
|
+
* browser.
|
|
1122
1174
|
*/
|
|
1123
1175
|
handleMouseEvent(event) {
|
|
1124
1176
|
if (event.kind === "wheelUp") {
|
|
@@ -1127,7 +1179,44 @@ export class TUI extends Container {
|
|
|
1127
1179
|
}
|
|
1128
1180
|
if (event.kind === "wheelDown") {
|
|
1129
1181
|
this.scrollByLines(WHEEL_LINES);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (event.kind === "press") {
|
|
1185
|
+
this.pressedCell = event.button === 0 ? { row: event.row, column: event.column } : undefined;
|
|
1186
|
+
return;
|
|
1130
1187
|
}
|
|
1188
|
+
if (event.kind !== "release")
|
|
1189
|
+
return;
|
|
1190
|
+
const pressed = this.pressedCell;
|
|
1191
|
+
this.pressedCell = undefined;
|
|
1192
|
+
if (!this.onHyperlink || !pressed)
|
|
1193
|
+
return;
|
|
1194
|
+
if (pressed.row !== event.row || pressed.column !== event.column)
|
|
1195
|
+
return;
|
|
1196
|
+
const url = this.hyperlinkAtScreenCell(event.row, event.column);
|
|
1197
|
+
if (url)
|
|
1198
|
+
this.onHyperlink(url);
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* The link on the screen cell a mouse report names, if there is one.
|
|
1202
|
+
*
|
|
1203
|
+
* Screen rows are turned into line-buffer rows through the window the last
|
|
1204
|
+
* frame painted — `scrollOffset` while pinned, `previousViewportTop` live —
|
|
1205
|
+
* so this answers from exactly what the terminal is showing. A live buffer
|
|
1206
|
+
* shorter than the screen is declined rather than guessed at: nothing pins
|
|
1207
|
+
* where such a frame starts on screen, and a wrong row is a click on the
|
|
1208
|
+
* wrong link.
|
|
1209
|
+
*/
|
|
1210
|
+
hyperlinkAtScreenCell(row, column) {
|
|
1211
|
+
const pinned = this.scrollOffset !== null;
|
|
1212
|
+
const lines = pinned ? this.scrollViewLines : this.previousLines;
|
|
1213
|
+
if (!lines || lines.length === 0)
|
|
1214
|
+
return undefined;
|
|
1215
|
+
if (!pinned && lines.length < this.terminal.rows)
|
|
1216
|
+
return undefined;
|
|
1217
|
+
const top = pinned ? (this.scrollOffset ?? 0) : this.previousViewportTop;
|
|
1218
|
+
const line = lines[top + row - 1];
|
|
1219
|
+
return line === undefined ? undefined : hyperlinkAt(line, column - 1);
|
|
1131
1220
|
}
|
|
1132
1221
|
/** Run a requested render now, bypassing the coalescing delay. Used for
|
|
1133
1222
|
* input-driven frames where echo latency matters more than batching. */
|
|
@@ -1612,18 +1701,24 @@ export class TUI extends Container {
|
|
|
1612
1701
|
// only where it is set.
|
|
1613
1702
|
const top = Math.min(Math.max(0, this.scrollOffset ?? 0), maxOffset);
|
|
1614
1703
|
this.scrollOffset = top;
|
|
1704
|
+
// Kept so a click on the pinned window can be placed against what it is
|
|
1705
|
+
// showing. Same array the render returned, so it costs a reference.
|
|
1706
|
+
this.scrollViewLines = lines;
|
|
1615
1707
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
1616
1708
|
buffer += HIDE_CURSOR;
|
|
1617
1709
|
// Autowrap off for the paint: a full-width row would otherwise wrap into
|
|
1618
1710
|
// the row below it and shift the rest of the window down by one.
|
|
1619
1711
|
buffer += "\x1b[?7l";
|
|
1712
|
+
// A kitty placement is not text and `CSI 2 K` does not touch it, so last
|
|
1713
|
+
// frame's pictures come off before this frame's rows go down.
|
|
1714
|
+
buffer += this.clearScrollImages();
|
|
1620
1715
|
this.refreshScrollSearch(lines.length);
|
|
1621
1716
|
const query = this.scrollSearch?.query ?? "";
|
|
1622
1717
|
for (let row = 0; row < viewHeight; row++) {
|
|
1623
1718
|
buffer += `\x1b[${row + 1};1H\x1b[2K`;
|
|
1624
1719
|
const line = lines[top + row];
|
|
1625
1720
|
if (line !== undefined)
|
|
1626
|
-
buffer += this.emitScrollLine(line, query);
|
|
1721
|
+
buffer += this.emitScrollLine(line, row, query);
|
|
1627
1722
|
}
|
|
1628
1723
|
buffer += `\x1b[${height};1H\x1b[2K`;
|
|
1629
1724
|
buffer += this.scrollStatusFormatter({
|
|
@@ -1645,24 +1740,70 @@ export class TUI extends Container {
|
|
|
1645
1740
|
// its full-redraw branch — which is exactly right, because the normal
|
|
1646
1741
|
// screen `?1049l` restored was drawn at the old size.
|
|
1647
1742
|
}
|
|
1743
|
+
/** Take the pinned window's own copies of the images off the screen. */
|
|
1744
|
+
clearScrollImages() {
|
|
1745
|
+
if (this.scrollPlacedImages.size === 0)
|
|
1746
|
+
return "";
|
|
1747
|
+
const buffer = this.deleteKittyImages(this.scrollPlacedImages);
|
|
1748
|
+
this.scrollPlacedImages.clear();
|
|
1749
|
+
return buffer;
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* On the way off the alternate screen: free the copies and the ids with
|
|
1753
|
+
* them, while there is still a screen to write to. The live ids are never
|
|
1754
|
+
* touched, so the normal screen comes back with its pictures intact.
|
|
1755
|
+
*/
|
|
1756
|
+
releaseScrollImages() {
|
|
1757
|
+
const buffer = this.clearScrollImages();
|
|
1758
|
+
if (buffer)
|
|
1759
|
+
this.terminal.write(buffer);
|
|
1760
|
+
this.scrollImageIds.clear();
|
|
1761
|
+
}
|
|
1648
1762
|
/**
|
|
1649
1763
|
* One transcript row, ready for the pinned window.
|
|
1650
1764
|
*
|
|
1651
|
-
*
|
|
1652
|
-
*
|
|
1653
|
-
*
|
|
1654
|
-
*
|
|
1655
|
-
*
|
|
1765
|
+
* `row` is where in the window the line lands, which decides whether an image
|
|
1766
|
+
* on it can be drawn at all: a picture reaches `imageRowOffset` rows *above*
|
|
1767
|
+
* its line, and a terminal will not draw above the first row — it clamps,
|
|
1768
|
+
* putting the picture over rows that are not its own and leaving it there.
|
|
1769
|
+
* A block hanging off the top of the window is named instead of drawn, so
|
|
1770
|
+
* scrolling one into view shows a placeholder until all of it is on screen.
|
|
1656
1771
|
*/
|
|
1657
|
-
emitScrollLine(line, query = "") {
|
|
1772
|
+
emitScrollLine(line, row, query = "") {
|
|
1658
1773
|
if (isImageLine(line))
|
|
1659
|
-
return
|
|
1774
|
+
return this.emitScrollImage(line, row);
|
|
1660
1775
|
const marker = line.indexOf(CURSOR_MARKER);
|
|
1661
1776
|
let text = marker === -1 ? line : line.slice(0, marker) + line.slice(marker + CURSOR_MARKER.length);
|
|
1662
1777
|
if (query.length > 0)
|
|
1663
1778
|
text = this.highlightScrollMatches(text, query);
|
|
1664
1779
|
return normalizeTerminalOutput(text) + TUI.SEGMENT_RESET;
|
|
1665
1780
|
}
|
|
1781
|
+
static IMAGE_PLACEHOLDER = "\x1b[2m[image]\x1b[0m";
|
|
1782
|
+
/** An image line in the pinned window: drawn if all of it fits, named if not. */
|
|
1783
|
+
emitScrollImage(line, row) {
|
|
1784
|
+
if (imageRowOffset(line) > row)
|
|
1785
|
+
return TUI.IMAGE_PLACEHOLDER;
|
|
1786
|
+
// iTerm2 paints into the cells it covers, so the next frame's `CSI 2 K`
|
|
1787
|
+
// clears it like any other row and there is nothing to track.
|
|
1788
|
+
if (!line.includes(KITTY_SEQUENCE_PREFIX))
|
|
1789
|
+
return line;
|
|
1790
|
+
const [liveId] = extractKittyImageIds(line);
|
|
1791
|
+
// A kitty placement with no id can never be deleted on its own, and the
|
|
1792
|
+
// only alternative deletes the live screen's pictures with it.
|
|
1793
|
+
if (liveId === undefined)
|
|
1794
|
+
return TUI.IMAGE_PLACEHOLDER;
|
|
1795
|
+
let pinnedId = this.scrollImageIds.get(liveId);
|
|
1796
|
+
if (pinnedId === undefined) {
|
|
1797
|
+
pinnedId = allocateImageId();
|
|
1798
|
+
this.scrollImageIds.set(liveId, pinnedId);
|
|
1799
|
+
}
|
|
1800
|
+
const retagged = retagKittyImageId(line, pinnedId);
|
|
1801
|
+
if (retagged === null)
|
|
1802
|
+
return TUI.IMAGE_PLACEHOLDER;
|
|
1803
|
+
this.scrollPlacedImages.add(pinnedId);
|
|
1804
|
+
this.sawImageLine = true;
|
|
1805
|
+
return retagged;
|
|
1806
|
+
}
|
|
1666
1807
|
doRender() {
|
|
1667
1808
|
if (this.stopped)
|
|
1668
1809
|
return;
|
|
@@ -1691,7 +1832,7 @@ export class TUI extends Container {
|
|
|
1691
1832
|
// invalidates the first's patch — it describes the buffer from before the
|
|
1692
1833
|
// splice — so fall back to the full scan, which is always correct and only
|
|
1693
1834
|
// runs on frames where the content height actually changed.
|
|
1694
|
-
if (this.fitFlexSpacer(newLines, height
|
|
1835
|
+
if (this.fitFlexSpacer(newLines, height)) {
|
|
1695
1836
|
newLines = this.render(width);
|
|
1696
1837
|
this.lastPatch = "full";
|
|
1697
1838
|
}
|
|
@@ -1840,6 +1981,57 @@ export class TUI extends Container {
|
|
|
1840
1981
|
lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
|
|
1841
1982
|
}
|
|
1842
1983
|
const appendStart = appendedLines && firstChanged === prevLineCount && firstChanged > 0;
|
|
1984
|
+
// The window has to move *back* over the buffer.
|
|
1985
|
+
//
|
|
1986
|
+
// On a session long enough to have scrolled, the terminal's own scroll is
|
|
1987
|
+
// what holds the buffer's last row on the screen's last row, and the screen
|
|
1988
|
+
// is the buffer's last `height` rows. Shrink the buffer — a picker closing,
|
|
1989
|
+
// a notification fading, a view dial folding a run of tool calls — and that
|
|
1990
|
+
// window slides back: the same last row, and rows the reader has not seen
|
|
1991
|
+
// since they scrolled past arriving at the top. Nothing can scroll a
|
|
1992
|
+
// terminal's own content *down* to bring them in, so the append-only path
|
|
1993
|
+
// cannot express this frame at all: it clears the rows that came off the end
|
|
1994
|
+
// and strands the prompt mid-screen with blanks under it.
|
|
1995
|
+
//
|
|
1996
|
+
// So the visible window is painted in place — one screenful, absolutely
|
|
1997
|
+
// addressed, which the app may do here precisely because a buffer taller
|
|
1998
|
+
// than the screen owns every row of it. It costs a screen of writes and no
|
|
1999
|
+
// clear, where the honest alternative is `\x1b[3J` and the whole transcript.
|
|
2000
|
+
// Only trees with a flex spacer take this path; an embedder without one
|
|
2001
|
+
// keeps the append-only frame it has always had.
|
|
2002
|
+
const windowTop = Math.max(0, newLines.length - height);
|
|
2003
|
+
if (this.flexSpacer && newLines.length < prevLineCount && windowTop < prevViewportTop) {
|
|
2004
|
+
// Two frames this cannot draw: a buffer that no longer fills the screen
|
|
2005
|
+
// (the rows below the window are not ours to leave stale) and a buffer
|
|
2006
|
+
// carrying images, which are placed against the screen and smear when the
|
|
2007
|
+
// rows under them move. Both are the case a full repaint is for.
|
|
2008
|
+
if (newLines.length < height || this.sawImageLine) {
|
|
2009
|
+
logRedraw(`window moved back (${prevViewportTop} -> ${windowTop})`);
|
|
2010
|
+
fullRender(true);
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
let buffer = "\x1b[?2026h";
|
|
2014
|
+
// A row painted to the last column would wrap, and the bottom row would
|
|
2015
|
+
// wrap into a scroll — the one thing this frame must not do.
|
|
2016
|
+
buffer += "\x1b[?7l";
|
|
2017
|
+
for (let row = 0; row < height; row++) {
|
|
2018
|
+
buffer += `\x1b[${row + 1};1H\x1b[2K`;
|
|
2019
|
+
buffer += this.emitLine(newLines[windowTop + row] ?? "");
|
|
2020
|
+
}
|
|
2021
|
+
buffer += "\x1b[?7h";
|
|
2022
|
+
this.cursorRow = newLines.length - 1;
|
|
2023
|
+
this.hardwareCursorRow = newLines.length - 1;
|
|
2024
|
+
buffer += this.buildHardwareCursorMove(cursorPos, newLines.length);
|
|
2025
|
+
buffer += "\x1b[?2026l";
|
|
2026
|
+
this.terminal.write(buffer);
|
|
2027
|
+
this.previousLines = newLines;
|
|
2028
|
+
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
2029
|
+
this.previousWidth = width;
|
|
2030
|
+
this.previousHeight = height;
|
|
2031
|
+
this.previousViewportTop = windowTop;
|
|
2032
|
+
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
1843
2035
|
// No changes - but still need to update hardware cursor position if it moved
|
|
1844
2036
|
if (firstChanged === -1) {
|
|
1845
2037
|
this.positionHardwareCursor(cursorPos, newLines.length);
|