@kolisachint/hoocode-tui 0.5.73 → 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,7 +8,7 @@ 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";
11
+ import { allocateImageId, deleteKittyImage, getCapabilities, isImageLine, setCellDimensions, } from "./terminal-image.js";
12
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) {
@@ -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;
@@ -517,6 +564,7 @@ export class TUI extends Container {
517
564
  return false;
518
565
  this.scrollOffset = null;
519
566
  this.scrollSearch = null;
567
+ this.releaseScrollImages();
520
568
  this.terminal.setAlternateScreen(false);
521
569
  // `?1049l` restores the normal screen, its scrollback and the cursor
522
570
  // exactly as they were at `?1049h`, and the snapshot taken on the way in
@@ -941,6 +989,7 @@ export class TUI extends Container {
941
989
  // the cursor relative to content that lives on the normal screen.
942
990
  if (this.scrollOffset !== null) {
943
991
  this.scrollOffset = null;
992
+ this.releaseScrollImages();
944
993
  this.terminal.setAlternateScreen(false);
945
994
  }
946
995
  this.stopped = true;
@@ -1660,13 +1709,16 @@ export class TUI extends Container {
1660
1709
  // Autowrap off for the paint: a full-width row would otherwise wrap into
1661
1710
  // the row below it and shift the rest of the window down by one.
1662
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();
1663
1715
  this.refreshScrollSearch(lines.length);
1664
1716
  const query = this.scrollSearch?.query ?? "";
1665
1717
  for (let row = 0; row < viewHeight; row++) {
1666
1718
  buffer += `\x1b[${row + 1};1H\x1b[2K`;
1667
1719
  const line = lines[top + row];
1668
1720
  if (line !== undefined)
1669
- buffer += this.emitScrollLine(line, query);
1721
+ buffer += this.emitScrollLine(line, row, query);
1670
1722
  }
1671
1723
  buffer += `\x1b[${height};1H\x1b[2K`;
1672
1724
  buffer += this.scrollStatusFormatter({
@@ -1688,24 +1740,70 @@ export class TUI extends Container {
1688
1740
  // its full-redraw branch — which is exactly right, because the normal
1689
1741
  // screen `?1049l` restored was drawn at the old size.
1690
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
+ }
1691
1762
  /**
1692
1763
  * One transcript row, ready for the pinned window.
1693
1764
  *
1694
- * Images are named rather than drawn. A kitty or iTerm image is placed by
1695
- * the cursor and sized in pixels, so the same escape replayed at a different
1696
- * screen row lands somewhere the window did not ask for and survives the
1697
- * frame that was supposed to replace it a smear across the view that no
1698
- * 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.
1699
1771
  */
1700
- emitScrollLine(line, query = "") {
1772
+ emitScrollLine(line, row, query = "") {
1701
1773
  if (isImageLine(line))
1702
- return "\x1b[2m[image]\x1b[0m";
1774
+ return this.emitScrollImage(line, row);
1703
1775
  const marker = line.indexOf(CURSOR_MARKER);
1704
1776
  let text = marker === -1 ? line : line.slice(0, marker) + line.slice(marker + CURSOR_MARKER.length);
1705
1777
  if (query.length > 0)
1706
1778
  text = this.highlightScrollMatches(text, query);
1707
1779
  return normalizeTerminalOutput(text) + TUI.SEGMENT_RESET;
1708
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
+ }
1709
1807
  doRender() {
1710
1808
  if (this.stopped)
1711
1809
  return;