@code-yeongyu/senpi-tui 2026.9.12 → 2026.9.13

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.
Files changed (94) hide show
  1. package/README.md +53 -4
  2. package/dist/alt-screen-search.d.ts +21 -1
  3. package/dist/alt-screen-search.d.ts.map +1 -1
  4. package/dist/alt-screen-search.js +199 -42
  5. package/dist/alt-screen-search.js.map +1 -1
  6. package/dist/components/box.d.ts +3 -1
  7. package/dist/components/box.d.ts.map +1 -1
  8. package/dist/components/box.js +28 -0
  9. package/dist/components/box.js.map +1 -1
  10. package/dist/components/editor.d.ts +6 -1
  11. package/dist/components/editor.d.ts.map +1 -1
  12. package/dist/components/editor.js +98 -18
  13. package/dist/components/editor.js.map +1 -1
  14. package/dist/components/input.d.ts +12 -1
  15. package/dist/components/input.d.ts.map +1 -1
  16. package/dist/components/input.js +40 -6
  17. package/dist/components/input.js.map +1 -1
  18. package/dist/components/latex.d.ts.map +1 -1
  19. package/dist/components/latex.js +7 -0
  20. package/dist/components/latex.js.map +1 -1
  21. package/dist/components/loader.d.ts +3 -0
  22. package/dist/components/loader.d.ts.map +1 -1
  23. package/dist/components/loader.js +20 -8
  24. package/dist/components/loader.js.map +1 -1
  25. package/dist/components/mouse-region.d.ts +12 -0
  26. package/dist/components/mouse-region.d.ts.map +1 -0
  27. package/dist/components/mouse-region.js +19 -0
  28. package/dist/components/mouse-region.js.map +1 -0
  29. package/dist/components/scroll-view.d.ts +6 -3
  30. package/dist/components/scroll-view.d.ts.map +1 -1
  31. package/dist/components/scroll-view.js +6 -1
  32. package/dist/components/scroll-view.js.map +1 -1
  33. package/dist/components/select-list.d.ts +4 -1
  34. package/dist/components/select-list.d.ts.map +1 -1
  35. package/dist/components/select-list.js +48 -2
  36. package/dist/components/select-list.js.map +1 -1
  37. package/dist/components/settings-list.d.ts +5 -1
  38. package/dist/components/settings-list.d.ts.map +1 -1
  39. package/dist/components/settings-list.js +54 -5
  40. package/dist/components/settings-list.js.map +1 -1
  41. package/dist/index.d.ts +3 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/layout.d.ts +3 -1
  46. package/dist/layout.d.ts.map +1 -1
  47. package/dist/layout.js +32 -9
  48. package/dist/layout.js.map +1 -1
  49. package/dist/native-modifiers.d.ts +2 -1
  50. package/dist/native-modifiers.d.ts.map +1 -1
  51. package/dist/native-modifiers.js +3 -44
  52. package/dist/native-modifiers.js.map +1 -1
  53. package/dist/native-platform.d.ts +18 -0
  54. package/dist/native-platform.d.ts.map +1 -0
  55. package/dist/native-platform.js +42 -0
  56. package/dist/native-platform.js.map +1 -0
  57. package/dist/terminal.d.ts.map +1 -1
  58. package/dist/terminal.js +2 -20
  59. package/dist/terminal.js.map +1 -1
  60. package/dist/tui-alt-screen.d.ts +31 -1
  61. package/dist/tui-alt-screen.d.ts.map +1 -1
  62. package/dist/tui-alt-screen.js +335 -41
  63. package/dist/tui-alt-screen.js.map +1 -1
  64. package/dist/tui.d.ts +83 -5
  65. package/dist/tui.d.ts.map +1 -1
  66. package/dist/tui.js +118 -4
  67. package/dist/tui.js.map +1 -1
  68. package/dist/utils.d.ts +2 -0
  69. package/dist/utils.d.ts.map +1 -1
  70. package/dist/utils.js +9 -0
  71. package/dist/utils.js.map +1 -1
  72. package/native/clipboard.h +170 -0
  73. package/native/darwin/README.md +8 -6
  74. package/native/darwin/build.sh +12 -5
  75. package/native/darwin/prebuilds/darwin-arm64/{darwin-modifiers.node → darwin-platform.node} +0 -0
  76. package/native/darwin/prebuilds/darwin-x64/{darwin-modifiers.node → darwin-platform.node} +0 -0
  77. package/native/darwin/src/darwin-platform.m +93 -0
  78. package/native/linux/README.md +23 -0
  79. package/native/linux/build.sh +65 -0
  80. package/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  81. package/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  82. package/native/linux/src/clipboard-worker.h +70 -0
  83. package/native/linux/src/linux-platform-x11.c +274 -0
  84. package/native/napi.h +89 -0
  85. package/native/win32/README.md +18 -7
  86. package/native/win32/build.mjs +7 -4
  87. package/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  88. package/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  89. package/native/win32/src/win32-platform.c +241 -0
  90. package/package.json +10 -3
  91. package/native/darwin/src/darwin-modifiers.c +0 -70
  92. package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  93. package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  94. package/native/win32/src/win32-console-mode.c +0 -119
@@ -1,13 +1,14 @@
1
1
  var _a;
2
- import { AltScreenSearchComponent, findAltScreenSearchMatches, getAltScreenSearchMatchKey, } from "./alt-screen-search.js";
2
+ import { AltScreenSearchComponent, AltScreenSearchIndex, getAltScreenSearchMatchKey, } from "./alt-screen-search.js";
3
3
  import { AltScreenFlashContainer } from "./components/alt-screen-flash.js";
4
4
  import { ScrollView } from "./components/scroll-view.js";
5
5
  import { getKeybindings } from "./keybindings.js";
6
6
  import { isKeyRelease } from "./keys.js";
7
- import { getScrollbarGeometry, getScrollViewBox, getScrollViewsAt, renderLayoutFrame, } from "./layout.js";
7
+ import { getLayoutBoxesAt, getScrollbarGeometry, getScrollViewBox, getScrollViewsAt, renderLayoutFrame, } from "./layout.js";
8
+ import { getLayoutNode } from "./layout-node.js";
8
9
  import { deleteAllKittyImages, deleteAllKittyPlacements, deleteKittyImage, getCapabilities, getKittyImagePlacement, isImageLine, setCapabilities, } from "./terminal-image.js";
9
- import { CURSOR_MARKER, compositeTuiLine, TuiBase, VIEWPORT_TUI, } from "./tui.js";
10
- import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, getWordSegmenter, sliceByColumn, stripTerminalSequences, visibleWidth, } from "./utils.js";
10
+ import { Container, CURSOR_MARKER, compositeTuiLine, dispatchMouseEvent, retargetMouseEvent, TuiBase, VIEWPORT_TUI, } from "./tui.js";
11
+ import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, getWordSegmenter, sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, } from "./utils.js";
11
12
  const ENTER_ALT_SCREEN = "\x1b[?1049h";
12
13
  const EXIT_ALT_SCREEN = "\x1b[?1049l";
13
14
  const DISABLE_AUTOWRAP = "\x1b[?7l";
@@ -22,6 +23,7 @@ const END_SYNCHRONIZED_OUTPUT = "\x1b[?2026l";
22
23
  const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/;
23
24
  const OSC133_PROMPT_START = /^\x1b\]133;A(?:\x07|\x1b\\)/;
24
25
  const PAGE_SCROLL_OVERLAP = 4;
26
+ const ALT_WHEEL_SCROLL_MULTIPLIER = 5;
25
27
  const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
26
28
  const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
27
29
  const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;
@@ -48,8 +50,10 @@ export class TuiAltScreen extends TuiBase {
48
50
  this.selectionAutoScrollDirection = 0;
49
51
  this.selectionPressActive = false;
50
52
  this.selectionDragged = false;
53
+ this.mousePressMoved = false;
51
54
  this.implicitDocument = {
52
55
  render: (width) => super.render(width),
56
+ handleMouse: (event) => super.handleMouse(event),
53
57
  invalidate: () => {
54
58
  for (const child of this.children)
55
59
  child.invalidate();
@@ -61,6 +65,8 @@ export class TuiAltScreen extends TuiBase {
61
65
  this.mouseEnabled = options.mouse ?? true;
62
66
  this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`);
63
67
  this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`);
68
+ this.searchNavigationButtonStyle = options.searchNavigationButtonStyle ?? ((text) => text);
69
+ this.scrollToEndIndicator = options.scrollToEndIndicator;
64
70
  this.openUrl = options.openUrl;
65
71
  this.onRightClickPaste = options.onRightClickPaste;
66
72
  this.copyOnSelect = options.copyOnSelect ?? true;
@@ -129,6 +135,8 @@ export class TuiAltScreen extends TuiBase {
129
135
  this.lastClick = undefined;
130
136
  this.pressedUrl = undefined;
131
137
  this.selectionDragged = false;
138
+ this.clearComponentMouseGesture();
139
+ this.lastComponentClick = undefined;
132
140
  this.resetRenderState();
133
141
  const term = process.env.TERM?.toLowerCase() ?? "";
134
142
  // Multiplexers can lag when every pointer movement is forwarded. Button-motion
@@ -148,6 +156,7 @@ export class TuiAltScreen extends TuiBase {
148
156
  this.selectionPressActive = false;
149
157
  this.stopScrollbarHover();
150
158
  this.stopScrollbarDrag();
159
+ this.clearComponentMouseGesture();
151
160
  this.flashes.dispose();
152
161
  if (!this.altScreenActive)
153
162
  return;
@@ -262,14 +271,15 @@ export class TuiAltScreen extends TuiBase {
262
271
  return;
263
272
  }
264
273
  }
265
- openSearch() {
274
+ toggleSearch() {
266
275
  if (this.activeSearch) {
267
- this.activeSearch.overlay?.focus();
276
+ this.closeSearch();
268
277
  return;
269
278
  }
270
- const component = new AltScreenSearchComponent((query) => this.updateSearchQuery(query));
279
+ const component = new AltScreenSearchComponent((query) => this.updateSearchQuery(query), this.searchNavigationButtonStyle);
271
280
  const search = {
272
281
  component,
282
+ index: new AltScreenSearchIndex(),
273
283
  query: "",
274
284
  matches: [],
275
285
  selectedIndex: -1,
@@ -280,7 +290,7 @@ export class TuiAltScreen extends TuiBase {
280
290
  search.overlay = this.showOverlay(component, {
281
291
  anchor: "top-right",
282
292
  width: "40%",
283
- minWidth: 24,
293
+ minWidth: 32,
284
294
  margin: 1,
285
295
  });
286
296
  }
@@ -310,6 +320,29 @@ export class TuiAltScreen extends TuiBase {
310
320
  search.selectionMode = direction < 0 ? "previous" : "next";
311
321
  this.requestRender();
312
322
  }
323
+ getSearchNavigationDirectionAt(x, y) {
324
+ const search = this.activeSearch;
325
+ const bounds = search?.overlay?.getBounds();
326
+ if (!search || !bounds)
327
+ return undefined;
328
+ if (x < bounds.col || x >= bounds.col + bounds.width || y < bounds.row || y >= bounds.row + bounds.height) {
329
+ return undefined;
330
+ }
331
+ return search.component.getNavigationDirectionAt(y - bounds.row, x - bounds.col);
332
+ }
333
+ handleSearchMouseEvent(event) {
334
+ const search = this.activeSearch;
335
+ if (!search)
336
+ return false;
337
+ const direction = this.getSearchNavigationDirectionAt(event.x, event.y);
338
+ if (search.component.setHoveredNavigationDirection(direction))
339
+ this.requestRender();
340
+ if (direction === undefined || event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0) {
341
+ return false;
342
+ }
343
+ this.navigateSearch(direction);
344
+ return true;
345
+ }
313
346
  refreshSearch(layout) {
314
347
  const search = this.activeSearch;
315
348
  if (!search)
@@ -326,16 +359,29 @@ export class TuiAltScreen extends TuiBase {
326
359
  return false;
327
360
  }
328
361
  const shouldRevealSelection = search.selectionMode !== "retain";
329
- const matches = findAltScreenSearchMatches(lines, search.query);
330
- const exactIndex = search.selectedKey
331
- ? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey)
332
- : -1;
362
+ const result = search.index.search(lines, search.query);
363
+ const matches = result.matches;
364
+ search.matches = matches;
365
+ if (!result.changed && search.selectionMode === "retain")
366
+ return false;
367
+ const exactIndex = result.changed
368
+ ? search.selectedKey
369
+ ? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey)
370
+ : -1
371
+ : search.selectedIndex;
333
372
  let selectedIndex = -1;
334
373
  if (matches.length > 0) {
335
374
  if (search.selectionMode === "query") {
336
- selectedIndex = matches.findIndex((match) => (match.segments[0]?.row ?? 0) >= search.anchorRow);
337
- if (selectedIndex < 0)
338
- selectedIndex = 0;
375
+ let low = 0;
376
+ let high = matches.length;
377
+ while (low < high) {
378
+ const middle = low + Math.floor((high - low) / 2);
379
+ if ((matches[middle].segments[0]?.row ?? 0) < search.anchorRow)
380
+ low = middle + 1;
381
+ else
382
+ high = middle;
383
+ }
384
+ selectedIndex = low < matches.length ? low : 0;
339
385
  }
340
386
  else if (search.selectionMode === "next") {
341
387
  const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1);
@@ -350,7 +396,6 @@ export class TuiAltScreen extends TuiBase {
350
396
  exactIndex >= 0 ? exactIndex : Math.min(Math.max(0, search.selectedIndex), matches.length - 1);
351
397
  }
352
398
  }
353
- search.matches = matches;
354
399
  search.selectedIndex = selectedIndex;
355
400
  search.selectedKey = selectedIndex >= 0 ? getAltScreenSearchMatchKey(matches[selectedIndex]) : undefined;
356
401
  search.selectionMode = "retain";
@@ -378,6 +423,12 @@ export class TuiAltScreen extends TuiBase {
378
423
  shouldDeferViewportInputToOverlay() {
379
424
  return this.isOverlayFocused() && this.activeSearch?.overlay?.isFocused() !== true;
380
425
  }
426
+ clearComponentMouseGesture() {
427
+ this.mouseCapture = undefined;
428
+ this.mousePressTarget = undefined;
429
+ this.mousePressPoint = undefined;
430
+ this.mousePressMoved = false;
431
+ }
381
432
  handleViewportInput(data) {
382
433
  if (data === FOCUS_OUT) {
383
434
  const hadActiveSelection = this.selectionPressActive;
@@ -385,9 +436,13 @@ export class TuiAltScreen extends TuiBase {
385
436
  this.selectionPressActive = false;
386
437
  this.stopSelectionAutoScroll();
387
438
  this.stopScrollbarHover();
439
+ if (this.activeSearch?.component.setHoveredNavigationDirection(undefined))
440
+ this.requestRender();
388
441
  this.stopScrollbarDrag();
389
442
  this.pressedUrl = undefined;
390
443
  this.selectionDragged = false;
444
+ this.clearComponentMouseGesture();
445
+ this.lastComponentClick = undefined;
391
446
  if (hadActiveSelection) {
392
447
  this.selectionAnchor = undefined;
393
448
  this.selectionFocus = undefined;
@@ -403,6 +458,16 @@ export class TuiAltScreen extends TuiBase {
403
458
  return { consume: true };
404
459
  const wheelEvent = this.parseWheelEvent(data);
405
460
  if (wheelEvent) {
461
+ const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, {
462
+ wheelDelta: wheelEvent.direction * this.getWheelScrollLines(wheelEvent.button),
463
+ });
464
+ const overlay = this.dispatchMouseToOverlay(event);
465
+ const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
466
+ if (result) {
467
+ if (this.applyMouseDispatchResult(event, result))
468
+ this.requestRender();
469
+ return { consume: true };
470
+ }
406
471
  if (this.shouldDeferViewportInputToOverlay())
407
472
  return undefined;
408
473
  this.routeWheel(wheelEvent);
@@ -410,13 +475,7 @@ export class TuiAltScreen extends TuiBase {
410
475
  }
411
476
  const mouseEvent = this.parseSgrMouseEvent(data);
412
477
  if (mouseEvent) {
413
- if (this.handleRightClickPaste(mouseEvent))
414
- return { consume: true };
415
- const handled = this.handleScrollbarMouseEvent(mouseEvent);
416
- if (!this.scrollbarDrag)
417
- this.updateScrollbarHover(mouseEvent.x, mouseEvent.y);
418
- if (!handled)
419
- this.handleSelectionMouseEvent(mouseEvent);
478
+ this.handleMouseEvent(mouseEvent);
420
479
  return { consume: true };
421
480
  }
422
481
  if (this.isMouseSequence(data))
@@ -425,7 +484,7 @@ export class TuiAltScreen extends TuiBase {
425
484
  const isRelease = isKeyRelease(data);
426
485
  if (keybindings.matches(data, "tui.altScreen.search")) {
427
486
  if (!isRelease)
428
- this.openSearch();
487
+ this.toggleSearch();
429
488
  return { consume: true };
430
489
  }
431
490
  if (this.activeSearch?.overlay?.isFocused()) {
@@ -501,6 +560,165 @@ export class TuiAltScreen extends TuiBase {
501
560
  }
502
561
  return undefined;
503
562
  }
563
+ decodeMouseButton(button) {
564
+ switch (button & 3) {
565
+ case 0:
566
+ return "left";
567
+ case 1:
568
+ return "middle";
569
+ case 2:
570
+ return "right";
571
+ default:
572
+ return "none";
573
+ }
574
+ }
575
+ createMouseEvent(type, button, x, y, extra = {}) {
576
+ return {
577
+ type,
578
+ button: type === "wheel" ? "none" : this.decodeMouseButton(button),
579
+ x,
580
+ y,
581
+ screenX: x,
582
+ screenY: y,
583
+ width: Math.max(1, this.terminal.columns),
584
+ height: Math.max(1, this.terminal.rows),
585
+ shift: (button & 4) !== 0,
586
+ alt: (button & 8) !== 0,
587
+ ctrl: (button & 16) !== 0,
588
+ ...(extra.wheelDelta === undefined ? {} : { wheelDelta: extra.wheelDelta }),
589
+ ...(extra.clickCount === undefined ? {} : { clickCount: extra.clickCount }),
590
+ };
591
+ }
592
+ dispatchMouseToLayout(event) {
593
+ if (!this.currentLayout)
594
+ return undefined;
595
+ const visited = new Set();
596
+ const boxes = getLayoutBoxesAt(this.currentLayout, event.screenX, event.screenY);
597
+ for (const box of boxes) {
598
+ if (visited.has(box.component))
599
+ continue;
600
+ if (getLayoutNode(box.component) && box.component.handleMouse === Container.prototype.handleMouse)
601
+ continue;
602
+ visited.add(box.component);
603
+ const result = dispatchMouseEvent(box.component, {
604
+ ...event,
605
+ x: event.screenX - box.rect.x,
606
+ y: event.screenY - box.rect.y,
607
+ width: box.rect.width,
608
+ height: box.rect.height,
609
+ });
610
+ if (result)
611
+ return result;
612
+ }
613
+ return undefined;
614
+ }
615
+ applyMouseDispatchResult(event, result) {
616
+ const focusTarget = this.resolveMouseFocusTarget(result.focusTarget ?? result.target.component);
617
+ const focusChanged = result.focus === true && this.getFocusedComponent() !== focusTarget;
618
+ if (result.focus)
619
+ this.setFocus(focusTarget);
620
+ if (result.capture)
621
+ this.mouseCapture = result.target;
622
+ return (result.render ??
623
+ (focusChanged ||
624
+ event.type === "press" ||
625
+ event.type === "click" ||
626
+ event.type === "drag" ||
627
+ event.type === "wheel"));
628
+ }
629
+ dispatchMouseToTarget(event, target) {
630
+ return dispatchMouseEvent(target.component, retargetMouseEvent(event, target));
631
+ }
632
+ getComponentClickCount(target, x, y) {
633
+ const now = Date.now();
634
+ const previous = this.lastComponentClick;
635
+ const count = previous &&
636
+ now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS &&
637
+ previous.component === target.component &&
638
+ previous.x === x &&
639
+ previous.y === y
640
+ ? (previous.count % 3) + 1
641
+ : 1;
642
+ this.lastComponentClick = { timestamp: now, count, component: target.component, x, y };
643
+ return count;
644
+ }
645
+ clearTextSelection() {
646
+ this.stopSelectionAutoScroll();
647
+ this.selectionPressActive = false;
648
+ this.selectionAnchor = undefined;
649
+ this.selectionFocus = undefined;
650
+ this.selectionGranularity = "character";
651
+ this.selectionInitialRange = undefined;
652
+ this.pressedUrl = undefined;
653
+ this.selectionDragged = false;
654
+ }
655
+ handleMouseEvent(raw) {
656
+ const isMotion = (raw.button & 32) !== 0;
657
+ const type = raw.release
658
+ ? "release"
659
+ : isMotion
660
+ ? this.decodeMouseButton(raw.button) === "none"
661
+ ? "move"
662
+ : "drag"
663
+ : "press";
664
+ const event = this.createMouseEvent(type, raw.button, raw.x, raw.y);
665
+ if (this.mouseCapture || this.mousePressTarget) {
666
+ const target = this.mouseCapture ?? this.mousePressTarget;
667
+ if (this.mousePressPoint && (raw.x !== this.mousePressPoint.x || raw.y !== this.mousePressPoint.y)) {
668
+ this.mousePressMoved = true;
669
+ this.lastComponentClick = undefined;
670
+ }
671
+ let render = false;
672
+ const targetResult = this.dispatchMouseToTarget(event, target);
673
+ if (targetResult)
674
+ render = this.applyMouseDispatchResult(event, targetResult);
675
+ if (raw.release) {
676
+ if (!this.mousePressMoved && this.mousePressPoint?.x === raw.x && this.mousePressPoint.y === raw.y) {
677
+ const clickEvent = this.createMouseEvent("click", raw.button, raw.x, raw.y, {
678
+ clickCount: this.getComponentClickCount(target, raw.x, raw.y),
679
+ });
680
+ const clickResult = this.dispatchMouseToTarget(clickEvent, target);
681
+ if (clickResult)
682
+ render = this.applyMouseDispatchResult(clickEvent, clickResult) || render;
683
+ }
684
+ this.clearComponentMouseGesture();
685
+ }
686
+ if (render)
687
+ this.requestRender();
688
+ return;
689
+ }
690
+ if (this.handleSearchMouseEvent(raw))
691
+ return;
692
+ const overlay = this.dispatchMouseToOverlay(event);
693
+ if (!overlay.hit) {
694
+ if (this.handleScrollToEndIndicatorMouseEvent(raw))
695
+ return;
696
+ const scrollbarHandled = this.handleScrollbarMouseEvent(raw);
697
+ if (!this.scrollbarDrag)
698
+ this.updateScrollbarHover(raw.x, raw.y);
699
+ if (scrollbarHandled)
700
+ return;
701
+ }
702
+ else {
703
+ this.stopScrollbarHover();
704
+ }
705
+ const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
706
+ if (result) {
707
+ const render = this.applyMouseDispatchResult(event, result);
708
+ if (type === "press") {
709
+ this.clearTextSelection();
710
+ this.mousePressTarget = result.target;
711
+ this.mousePressPoint = { x: raw.x, y: raw.y };
712
+ this.mousePressMoved = false;
713
+ }
714
+ if (render)
715
+ this.requestRender();
716
+ return;
717
+ }
718
+ if (this.handleRightClickPaste(raw))
719
+ return;
720
+ this.handleSelectionMouseEvent(raw);
721
+ }
504
722
  parseWheelEvent(data) {
505
723
  const sgr = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
506
724
  if (sgr) {
@@ -514,6 +732,7 @@ export class TuiAltScreen extends TuiBase {
514
732
  direction: direction === 0 ? -1 : 1,
515
733
  x: Number.parseInt(sgr[2], 10) - 1,
516
734
  y: Number.parseInt(sgr[3], 10) - 1,
735
+ button,
517
736
  };
518
737
  }
519
738
  if (data.length === 6 && data.startsWith("\x1b[M")) {
@@ -527,12 +746,17 @@ export class TuiAltScreen extends TuiBase {
527
746
  direction: direction === 0 ? -1 : 1,
528
747
  x: data.charCodeAt(4) - 33,
529
748
  y: data.charCodeAt(5) - 33,
749
+ button,
530
750
  };
531
751
  }
532
752
  return undefined;
533
753
  }
754
+ getWheelScrollLines(button) {
755
+ // SGR mouse button codes use bit 3 (value 8) for the Alt modifier.
756
+ return (button & 8) !== 0 ? this.wheelScrollLines * ALT_WHEEL_SCROLL_MULTIPLIER : this.wheelScrollLines;
757
+ }
534
758
  routeWheel(event) {
535
- let remaining = event.direction * this.wheelScrollLines;
759
+ let remaining = event.direction * this.getWheelScrollLines(event.button);
536
760
  const seen = new Set();
537
761
  for (const scrollView of this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y) : []) {
538
762
  seen.add(scrollView);
@@ -573,16 +797,25 @@ export class TuiAltScreen extends TuiBase {
573
797
  }
574
798
  return true;
575
799
  }
576
- getScrollbarTargetAt(x, y) {
800
+ handleScrollToEndIndicatorMouseEvent(event) {
801
+ const rect = this.scrollToEndIndicatorRect;
802
+ if (!rect || event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0)
803
+ return false;
804
+ if (event.y !== rect.row || event.x < rect.column || event.x >= rect.column + rect.width)
805
+ return false;
806
+ this.scrollToBottom();
807
+ return true;
808
+ }
809
+ getScrollbarTargetAt(x, y, includeHiddenAuto = false) {
577
810
  if (this.hasOverlay() || !this.currentLayout)
578
811
  return undefined;
579
812
  for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) {
580
813
  const box = getScrollViewBox(this.currentLayout, scrollView);
581
- const geometry = box ? getScrollbarGeometry(box) : undefined;
814
+ const geometry = box ? getScrollbarGeometry(box, includeHiddenAuto) : undefined;
582
815
  if (geometry &&
583
816
  x === geometry.column &&
584
- y >= geometry.thumbTop &&
585
- y < geometry.thumbTop + geometry.thumbHeight) {
817
+ y >= geometry.trackTop &&
818
+ y < geometry.trackTop + geometry.trackHeight) {
586
819
  return { scrollView, geometry };
587
820
  }
588
821
  }
@@ -596,11 +829,17 @@ export class TuiAltScreen extends TuiBase {
596
829
  this.scrollbarHover?.setScrollbarActive(true);
597
830
  }
598
831
  updateScrollbarHover(x, y) {
599
- this.setScrollbarHover(this.getScrollbarTargetAt(x, y)?.scrollView);
832
+ this.setScrollbarHover(this.getScrollbarTargetAt(x, y, true)?.scrollView);
600
833
  }
601
834
  stopScrollbarHover() {
602
835
  this.setScrollbarHover(undefined);
603
836
  }
837
+ scrollScrollbarToPointer(scrollView, geometry, pointerY, grabOffset) {
838
+ const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight;
839
+ const thumbOffset = Math.max(0, Math.min(maxThumbOffset, pointerY - geometry.trackTop - grabOffset));
840
+ const scrollTop = maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop);
841
+ scrollView.scrollTo(scrollTop);
842
+ }
604
843
  handleScrollbarMouseEvent(event) {
605
844
  if (this.scrollbarDrag) {
606
845
  if (event.release) {
@@ -612,10 +851,7 @@ export class TuiAltScreen extends TuiBase {
612
851
  : undefined;
613
852
  const geometry = box ? getScrollbarGeometry(box) : undefined;
614
853
  if (geometry) {
615
- const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight;
616
- const thumbOffset = Math.max(0, Math.min(maxThumbOffset, event.y - geometry.trackTop - this.scrollbarDrag.grabOffset));
617
- const scrollTop = maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop);
618
- this.scrollbarDrag.scrollView.scrollTo(scrollTop);
854
+ this.scrollScrollbarToPointer(this.scrollbarDrag.scrollView, geometry, event.y, this.scrollbarDrag.grabOffset);
619
855
  }
620
856
  return true;
621
857
  }
@@ -634,9 +870,13 @@ export class TuiAltScreen extends TuiBase {
634
870
  this.pressedUrl = undefined;
635
871
  this.selectionDragged = false;
636
872
  this.setScrollbarHover(target.scrollView);
873
+ const onThumb = event.y >= target.geometry.thumbTop && event.y < target.geometry.thumbTop + target.geometry.thumbHeight;
874
+ const grabOffset = onThumb ? event.y - target.geometry.thumbTop : Math.floor(target.geometry.thumbHeight / 2);
875
+ if (!onThumb)
876
+ this.scrollScrollbarToPointer(target.scrollView, target.geometry, event.y, grabOffset);
637
877
  this.scrollbarDrag = {
638
878
  scrollView: target.scrollView,
639
- grabOffset: event.y - target.geometry.thumbTop,
879
+ grabOffset,
640
880
  };
641
881
  return true;
642
882
  }
@@ -821,12 +1061,11 @@ export class TuiAltScreen extends TuiBase {
821
1061
  if (!this.selectionAnchor)
822
1062
  return;
823
1063
  this.updateSelectionFocus(point);
824
- const clickedUrl = !this.selectionDragged &&
1064
+ const isClick = !this.selectionDragged &&
825
1065
  this.selectionAnchor.scrollView === point.scrollView &&
826
1066
  this.selectionAnchor.row === point.row &&
827
- this.selectionAnchor.col === point.col
828
- ? this.pressedUrl
829
- : undefined;
1067
+ this.selectionAnchor.col === point.col;
1068
+ const clickedUrl = isClick ? this.pressedUrl : undefined;
830
1069
  this.pressedUrl = undefined;
831
1070
  if (clickedUrl && this.openUrl) {
832
1071
  this.selectionAnchor = undefined;
@@ -840,6 +1079,20 @@ export class TuiAltScreen extends TuiBase {
840
1079
  this.requestRender();
841
1080
  return;
842
1081
  }
1082
+ if (isClick) {
1083
+ const clickEvent = this.createMouseEvent("click", event.button, event.x, event.y, {
1084
+ clickCount: this.lastClick?.count ?? 1,
1085
+ });
1086
+ const overlay = this.dispatchMouseToOverlay(clickEvent);
1087
+ const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(clickEvent));
1088
+ if (result) {
1089
+ const render = this.applyMouseDispatchResult(clickEvent, result);
1090
+ this.clearTextSelection();
1091
+ if (render)
1092
+ this.requestRender();
1093
+ return;
1094
+ }
1095
+ }
843
1096
  if (this.copyOnSelect)
844
1097
  void this.copySelectionToClipboard();
845
1098
  this.requestRender();
@@ -981,8 +1234,24 @@ export class TuiAltScreen extends TuiBase {
981
1234
  const maxRow = Math.min(screen.length, box.rect.y + box.rect.height, box.clip.y + box.clip.height);
982
1235
  const minColumn = Math.max(0, box.rect.x, box.clip.x);
983
1236
  const maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width, scrollbarColumn ?? Number.POSITIVE_INFINITY);
984
- for (let matchIndex = 0; matchIndex < search.matches.length; matchIndex++) {
985
- for (const segment of search.matches[matchIndex].segments) {
1237
+ const minContentRow = scrollView.scrollTop + minRow - box.rect.y;
1238
+ const maxContentRow = scrollView.scrollTop + maxRow - box.rect.y - 1;
1239
+ let low = 0;
1240
+ let high = search.matches.length;
1241
+ while (low < high) {
1242
+ const middle = low + Math.floor((high - low) / 2);
1243
+ const match = search.matches[middle];
1244
+ const lastRow = match.segments[match.segments.length - 1]?.row ?? -1;
1245
+ if (lastRow < minContentRow)
1246
+ low = middle + 1;
1247
+ else
1248
+ high = middle;
1249
+ }
1250
+ for (let matchIndex = low; matchIndex < search.matches.length; matchIndex++) {
1251
+ const match = search.matches[matchIndex];
1252
+ if ((match.segments[0]?.row ?? 0) > maxContentRow)
1253
+ break;
1254
+ for (const segment of match.segments) {
986
1255
  const row = box.rect.y + segment.row - scrollView.scrollTop;
987
1256
  if (row < minRow || row >= maxRow)
988
1257
  continue;
@@ -1085,6 +1354,30 @@ export class TuiAltScreen extends TuiBase {
1085
1354
  isMouseSequence(data) {
1086
1355
  return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data) || (data.length === 6 && data.startsWith("\x1b[M"));
1087
1356
  }
1357
+ compositeScrollToEndIndicator(screen, layout, width) {
1358
+ this.scrollToEndIndicatorRect = undefined;
1359
+ const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
1360
+ if (!this.scrollToEndIndicator || !scrollView.followEnd || scrollView.isFollowingEnd)
1361
+ return screen;
1362
+ const box = getScrollViewBox(layout, scrollView);
1363
+ const clip = box?.clip;
1364
+ if (!clip || clip.width <= 0 || clip.height <= 0)
1365
+ return screen;
1366
+ const row = clip.y + clip.height - 1;
1367
+ if (row >= screen.length || isImageLine(screen[row] ?? ""))
1368
+ return screen;
1369
+ const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined;
1370
+ const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x);
1371
+ const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, "");
1372
+ const textWidth = visibleWidth(text);
1373
+ if (textWidth === 0)
1374
+ return screen;
1375
+ const column = clip.x + Math.floor((availableWidth - textWidth) / 2);
1376
+ const result = [...screen];
1377
+ result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width);
1378
+ this.scrollToEndIndicatorRect = { row, column, width: textWidth };
1379
+ return result;
1380
+ }
1088
1381
  compositeFlashes(screen, width, height) {
1089
1382
  const flashLines = this.flashes.render(width).slice(-height);
1090
1383
  if (flashLines.length === 0)
@@ -1113,6 +1406,7 @@ export class TuiAltScreen extends TuiBase {
1113
1406
  }
1114
1407
  let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
1115
1408
  screen = this.applySearchHighlights(screen, nextLayout);
1409
+ screen = this.compositeScrollToEndIndicator(screen, nextLayout, width);
1116
1410
  screen = this.compositeOverlays(screen, width, height);
1117
1411
  if (screen.length > height)
1118
1412
  screen = screen.slice(screen.length - height);