@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/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 should sit between the part
398
- * of the tree that flows from the top and the chrome that hangs off the
399
- * bottom everything after it is what gets pinned to the foot of the screen.
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
- * Two cases, and the second one is the one with a bug behind it.
413
- *
414
- * **The frame fits on the screen.** Fill it out to the screen's height and
415
- * the frame starts on the first row, which is the whole point of the fill.
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
- * **The frame is taller than the screen.** Then the fill is not what puts
418
- * the prompt on the floor the terminal's own scroll is, and the buffer's
419
- * last row *is* the screen's last row. So a buffer that gets *shorter*
420
- * takes the prompt up the screen with it: the renderer clears the rows that
421
- * came off the end and there is nothing it can do to scroll the transcript
422
- * back down into them, because those rows are in the terminal's scrollback
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, repaint = false) {
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
- if (content < height)
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
- * Only the wheel is acted on. Clicks are swallowed rather than handled:
1119
- * reporting is on for the wheel's sake, and a click that fell through to the
1120
- * focused component would arrive as the raw report text in whatever field
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
- * Images are named rather than drawn. A kitty or iTerm image is placed by
1652
- * the cursor and sized in pixels, so the same escape replayed at a different
1653
- * screen row lands somewhere the window did not ask for and survives the
1654
- * frame that was supposed to replace it a smear across the view that no
1655
- * later repaint can clear.
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 "\x1b[2m[image]\x1b[0m";
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, widthChanged || heightChanged)) {
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);