@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/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 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.
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
- * 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.
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
- * **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.
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, repaint = false) {
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
- 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));
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
- * 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.
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, widthChanged || heightChanged)) {
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);