@kolisachint/hoocode-tui 0.5.72 → 0.5.73
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 +10 -3
- package/dist/components/box.d.ts.map +1 -1
- package/dist/components/box.js +22 -14
- 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 +53 -31
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +137 -43
- 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
|
@@ -9,7 +9,7 @@ import { stripVTControlCharacters } from "node:util";
|
|
|
9
9
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
10
10
|
import { mouseSequenceLength, parseMouseEvent } from "./mouse.js";
|
|
11
11
|
import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
12
|
-
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, truncateToWidth, visibleWidth, } from "./utils.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);
|
|
@@ -313,6 +313,18 @@ export class TUI extends Container {
|
|
|
313
313
|
* any embedder outside the app rely on.
|
|
314
314
|
*/
|
|
315
315
|
flexSpacer;
|
|
316
|
+
/** Where the left button went down, for telling a click from a drag. */
|
|
317
|
+
pressedCell;
|
|
318
|
+
/** What the pinned window painted last, so a click on it can be placed. */
|
|
319
|
+
scrollViewLines;
|
|
320
|
+
/**
|
|
321
|
+
* Open the URL behind a clicked hyperlink. Unset, clicks do nothing.
|
|
322
|
+
*
|
|
323
|
+
* The TUI resolves *which* link was clicked and deliberately stops there:
|
|
324
|
+
* opening a URL is spawning a process, which is the embedder's policy to
|
|
325
|
+
* make, not a rendering library's.
|
|
326
|
+
*/
|
|
327
|
+
onHyperlink;
|
|
316
328
|
/**
|
|
317
329
|
* The pinned viewport.
|
|
318
330
|
*
|
|
@@ -394,9 +406,10 @@ export class TUI extends Container {
|
|
|
394
406
|
/**
|
|
395
407
|
* Nominate the child that absorbs the leftover rows (see `flexSpacer`).
|
|
396
408
|
*
|
|
397
|
-
* It must already be a child of the root, and it
|
|
398
|
-
*
|
|
399
|
-
* bottom
|
|
409
|
+
* It must already be a child of the root, and it belongs at the *top* of the
|
|
410
|
+
* tree: everything after it is pushed to the foot of the screen, so the app
|
|
411
|
+
* reads bottom-up the way a terminal does — the newest row next to the
|
|
412
|
+
* prompt, and whatever room is left over above the first thing drawn.
|
|
400
413
|
*/
|
|
401
414
|
setFlexSpacer(spacer) {
|
|
402
415
|
this.flexSpacer = spacer;
|
|
@@ -409,45 +422,26 @@ export class TUI extends Container {
|
|
|
409
422
|
* frame that answers it is always the second one. Both passes are cheap
|
|
410
423
|
* after the first: every other child returns its memoized array untouched.
|
|
411
424
|
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
425
|
+
* The whole rule is one line, and it is the same one in both directions:
|
|
426
|
+
* the buffer is never shorter than the screen. A session that fits gets the
|
|
427
|
+
* difference as blank rows, which — with the spacer at the top of the tree —
|
|
428
|
+
* land *above* the first thing drawn, so the transcript always ends against
|
|
429
|
+
* the prompt. A session too long to fit gets nothing, because the terminal's
|
|
430
|
+
* own scroll is already holding the last row on the last row.
|
|
416
431
|
*
|
|
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.
|
|
432
|
+
* It deliberately does not bank rows a shrinking frame gave up. That kept
|
|
433
|
+
* the prompt on the floor, but it paid for it with a band of blank rows
|
|
434
|
+
* between the conversation and the prompt — a screenful of it when a view
|
|
435
|
+
* dial folded the transcript — which is the thing the fill exists to avoid.
|
|
436
|
+
* A buffer that has to move back over the screen is repainted instead; see
|
|
437
|
+
* the window-repaint branch in `doRender`.
|
|
435
438
|
*/
|
|
436
|
-
fitFlexSpacer(lines, height
|
|
439
|
+
fitFlexSpacer(lines, height) {
|
|
437
440
|
const spacer = this.flexSpacer;
|
|
438
441
|
if (!spacer)
|
|
439
442
|
return false;
|
|
440
443
|
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));
|
|
444
|
+
return spacer.setHeight(Math.max(0, height - content));
|
|
451
445
|
}
|
|
452
446
|
/**
|
|
453
447
|
* The screen rows a pinned window shows, the last one being the indicator.
|
|
@@ -534,6 +528,7 @@ export class TUI extends Container {
|
|
|
534
528
|
// the wrong ones.
|
|
535
529
|
this.flatCache = undefined;
|
|
536
530
|
this.lastCursorPos = undefined;
|
|
531
|
+
this.scrollViewLines = undefined;
|
|
537
532
|
this.requestRender();
|
|
538
533
|
return true;
|
|
539
534
|
}
|
|
@@ -1113,12 +1108,20 @@ export class TUI extends Container {
|
|
|
1113
1108
|
return out.length > 0 ? out : null;
|
|
1114
1109
|
}
|
|
1115
1110
|
/**
|
|
1116
|
-
* What the mouse does.
|
|
1111
|
+
* What the mouse does: the wheel scrolls, and a click opens a link.
|
|
1112
|
+
*
|
|
1113
|
+
* The click is here because capturing the mouse took it away. A terminal
|
|
1114
|
+
* resolves a click on an OSC 8 hyperlink itself right up until an app turns
|
|
1115
|
+
* reporting on, at which point the report comes to the app and the link stops
|
|
1116
|
+
* working — so the app owes the user an answer. It is the same answer the
|
|
1117
|
+
* terminal would have given: the URL under the pointer, handed to whoever set
|
|
1118
|
+
* `onHyperlink`. Anything else is still swallowed, because a click that fell
|
|
1119
|
+
* through to the focused component would arrive as raw report text typed into
|
|
1120
|
+
* whatever field has focus.
|
|
1117
1121
|
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
* has focus.
|
|
1122
|
+
* Press and release both have to land on the same cell. A drag that happens
|
|
1123
|
+
* to end on a link is someone selecting text, not someone asking for a
|
|
1124
|
+
* browser.
|
|
1122
1125
|
*/
|
|
1123
1126
|
handleMouseEvent(event) {
|
|
1124
1127
|
if (event.kind === "wheelUp") {
|
|
@@ -1127,7 +1130,44 @@ export class TUI extends Container {
|
|
|
1127
1130
|
}
|
|
1128
1131
|
if (event.kind === "wheelDown") {
|
|
1129
1132
|
this.scrollByLines(WHEEL_LINES);
|
|
1133
|
+
return;
|
|
1130
1134
|
}
|
|
1135
|
+
if (event.kind === "press") {
|
|
1136
|
+
this.pressedCell = event.button === 0 ? { row: event.row, column: event.column } : undefined;
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (event.kind !== "release")
|
|
1140
|
+
return;
|
|
1141
|
+
const pressed = this.pressedCell;
|
|
1142
|
+
this.pressedCell = undefined;
|
|
1143
|
+
if (!this.onHyperlink || !pressed)
|
|
1144
|
+
return;
|
|
1145
|
+
if (pressed.row !== event.row || pressed.column !== event.column)
|
|
1146
|
+
return;
|
|
1147
|
+
const url = this.hyperlinkAtScreenCell(event.row, event.column);
|
|
1148
|
+
if (url)
|
|
1149
|
+
this.onHyperlink(url);
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* The link on the screen cell a mouse report names, if there is one.
|
|
1153
|
+
*
|
|
1154
|
+
* Screen rows are turned into line-buffer rows through the window the last
|
|
1155
|
+
* frame painted — `scrollOffset` while pinned, `previousViewportTop` live —
|
|
1156
|
+
* so this answers from exactly what the terminal is showing. A live buffer
|
|
1157
|
+
* shorter than the screen is declined rather than guessed at: nothing pins
|
|
1158
|
+
* where such a frame starts on screen, and a wrong row is a click on the
|
|
1159
|
+
* wrong link.
|
|
1160
|
+
*/
|
|
1161
|
+
hyperlinkAtScreenCell(row, column) {
|
|
1162
|
+
const pinned = this.scrollOffset !== null;
|
|
1163
|
+
const lines = pinned ? this.scrollViewLines : this.previousLines;
|
|
1164
|
+
if (!lines || lines.length === 0)
|
|
1165
|
+
return undefined;
|
|
1166
|
+
if (!pinned && lines.length < this.terminal.rows)
|
|
1167
|
+
return undefined;
|
|
1168
|
+
const top = pinned ? (this.scrollOffset ?? 0) : this.previousViewportTop;
|
|
1169
|
+
const line = lines[top + row - 1];
|
|
1170
|
+
return line === undefined ? undefined : hyperlinkAt(line, column - 1);
|
|
1131
1171
|
}
|
|
1132
1172
|
/** Run a requested render now, bypassing the coalescing delay. Used for
|
|
1133
1173
|
* input-driven frames where echo latency matters more than batching. */
|
|
@@ -1612,6 +1652,9 @@ export class TUI extends Container {
|
|
|
1612
1652
|
// only where it is set.
|
|
1613
1653
|
const top = Math.min(Math.max(0, this.scrollOffset ?? 0), maxOffset);
|
|
1614
1654
|
this.scrollOffset = top;
|
|
1655
|
+
// Kept so a click on the pinned window can be placed against what it is
|
|
1656
|
+
// showing. Same array the render returned, so it costs a reference.
|
|
1657
|
+
this.scrollViewLines = lines;
|
|
1615
1658
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
1616
1659
|
buffer += HIDE_CURSOR;
|
|
1617
1660
|
// Autowrap off for the paint: a full-width row would otherwise wrap into
|
|
@@ -1691,7 +1734,7 @@ export class TUI extends Container {
|
|
|
1691
1734
|
// invalidates the first's patch — it describes the buffer from before the
|
|
1692
1735
|
// splice — so fall back to the full scan, which is always correct and only
|
|
1693
1736
|
// runs on frames where the content height actually changed.
|
|
1694
|
-
if (this.fitFlexSpacer(newLines, height
|
|
1737
|
+
if (this.fitFlexSpacer(newLines, height)) {
|
|
1695
1738
|
newLines = this.render(width);
|
|
1696
1739
|
this.lastPatch = "full";
|
|
1697
1740
|
}
|
|
@@ -1840,6 +1883,57 @@ export class TUI extends Container {
|
|
|
1840
1883
|
lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
|
|
1841
1884
|
}
|
|
1842
1885
|
const appendStart = appendedLines && firstChanged === prevLineCount && firstChanged > 0;
|
|
1886
|
+
// The window has to move *back* over the buffer.
|
|
1887
|
+
//
|
|
1888
|
+
// On a session long enough to have scrolled, the terminal's own scroll is
|
|
1889
|
+
// what holds the buffer's last row on the screen's last row, and the screen
|
|
1890
|
+
// is the buffer's last `height` rows. Shrink the buffer — a picker closing,
|
|
1891
|
+
// a notification fading, a view dial folding a run of tool calls — and that
|
|
1892
|
+
// window slides back: the same last row, and rows the reader has not seen
|
|
1893
|
+
// since they scrolled past arriving at the top. Nothing can scroll a
|
|
1894
|
+
// terminal's own content *down* to bring them in, so the append-only path
|
|
1895
|
+
// cannot express this frame at all: it clears the rows that came off the end
|
|
1896
|
+
// and strands the prompt mid-screen with blanks under it.
|
|
1897
|
+
//
|
|
1898
|
+
// So the visible window is painted in place — one screenful, absolutely
|
|
1899
|
+
// addressed, which the app may do here precisely because a buffer taller
|
|
1900
|
+
// than the screen owns every row of it. It costs a screen of writes and no
|
|
1901
|
+
// clear, where the honest alternative is `\x1b[3J` and the whole transcript.
|
|
1902
|
+
// Only trees with a flex spacer take this path; an embedder without one
|
|
1903
|
+
// keeps the append-only frame it has always had.
|
|
1904
|
+
const windowTop = Math.max(0, newLines.length - height);
|
|
1905
|
+
if (this.flexSpacer && newLines.length < prevLineCount && windowTop < prevViewportTop) {
|
|
1906
|
+
// Two frames this cannot draw: a buffer that no longer fills the screen
|
|
1907
|
+
// (the rows below the window are not ours to leave stale) and a buffer
|
|
1908
|
+
// carrying images, which are placed against the screen and smear when the
|
|
1909
|
+
// rows under them move. Both are the case a full repaint is for.
|
|
1910
|
+
if (newLines.length < height || this.sawImageLine) {
|
|
1911
|
+
logRedraw(`window moved back (${prevViewportTop} -> ${windowTop})`);
|
|
1912
|
+
fullRender(true);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
let buffer = "\x1b[?2026h";
|
|
1916
|
+
// A row painted to the last column would wrap, and the bottom row would
|
|
1917
|
+
// wrap into a scroll — the one thing this frame must not do.
|
|
1918
|
+
buffer += "\x1b[?7l";
|
|
1919
|
+
for (let row = 0; row < height; row++) {
|
|
1920
|
+
buffer += `\x1b[${row + 1};1H\x1b[2K`;
|
|
1921
|
+
buffer += this.emitLine(newLines[windowTop + row] ?? "");
|
|
1922
|
+
}
|
|
1923
|
+
buffer += "\x1b[?7h";
|
|
1924
|
+
this.cursorRow = newLines.length - 1;
|
|
1925
|
+
this.hardwareCursorRow = newLines.length - 1;
|
|
1926
|
+
buffer += this.buildHardwareCursorMove(cursorPos, newLines.length);
|
|
1927
|
+
buffer += "\x1b[?2026l";
|
|
1928
|
+
this.terminal.write(buffer);
|
|
1929
|
+
this.previousLines = newLines;
|
|
1930
|
+
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
1931
|
+
this.previousWidth = width;
|
|
1932
|
+
this.previousHeight = height;
|
|
1933
|
+
this.previousViewportTop = windowTop;
|
|
1934
|
+
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1843
1937
|
// No changes - but still need to update hardware cursor position if it moved
|
|
1844
1938
|
if (firstChanged === -1) {
|
|
1845
1939
|
this.positionHardwareCursor(cursorPos, newLines.length);
|