@earendil-works/pi-tui 0.84.0 → 0.84.2

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.
@@ -1,3 +1,4 @@
1
+ import { AltScreenSearchComponent, findAltScreenSearchMatches, getAltScreenSearchMatchKey, } from "./alt-screen-search.js";
1
2
  import { AltScreenFlashContainer } from "./components/alt-screen-flash.js";
2
3
  import { ScrollView } from "./components/scroll-view.js";
3
4
  import { getKeybindings } from "./keybindings.js";
@@ -5,12 +6,13 @@ import { isKeyRelease } from "./keys.js";
5
6
  import { getScrollbarGeometry, getScrollViewBox, getScrollViewsAt, renderLayoutFrame, } from "./layout.js";
6
7
  import { deleteAllKittyImages, deleteAllKittyPlacements, deleteKittyImage, getCapabilities, getKittyImagePlacement, isImageLine, setCapabilities, } from "./terminal-image.js";
7
8
  import { CURSOR_MARKER, compositeTuiLine, TuiBase, VIEWPORT_TUI, } from "./tui.js";
8
- import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, sliceByColumn, stripTerminalSequences, visibleWidth, } from "./utils.js";
9
+ import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, getWordSegmenter, sliceByColumn, stripTerminalSequences, visibleWidth, } from "./utils.js";
9
10
  const ENTER_ALT_SCREEN = "\x1b[?1049h";
10
11
  const EXIT_ALT_SCREEN = "\x1b[?1049l";
11
12
  const DISABLE_AUTOWRAP = "\x1b[?7l";
12
13
  const ENABLE_AUTOWRAP = "\x1b[?7h";
13
- const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
14
+ const ENABLE_BUTTON_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h";
15
+ const ENABLE_ALL_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
14
16
  const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
15
17
  const FOCUS_IN = "\x1b[I";
16
18
  const FOCUS_OUT = "\x1b[O";
@@ -22,6 +24,8 @@ const PAGE_SCROLL_OVERLAP = 4;
22
24
  const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
23
25
  const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
24
26
  const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;
27
+ const DOUBLE_CLICK_INTERVAL_MS = 500;
28
+ const wordSegmenter = getWordSegmenter();
25
29
  /** Alternate-screen TUI with a scrollable, application-owned viewport. */
26
30
  export class TuiAltScreen extends TuiBase {
27
31
  mode = "fullscreen";
@@ -41,17 +45,25 @@ export class TuiAltScreen extends TuiBase {
41
45
  uploadedKittyImages = new Map();
42
46
  selectionAnchor;
43
47
  selectionFocus;
48
+ selectionGranularity = "character";
49
+ selectionInitialRange;
50
+ lastClick;
44
51
  selectionDragPointer;
45
52
  selectionAutoScrollDirection = 0;
46
53
  selectionAutoScrollTimer;
47
54
  selectionPressActive = false;
48
55
  scrollbarDrag;
49
56
  scrollbarHover;
57
+ activeSearch;
50
58
  pressedUrl;
51
59
  selectionDragged = false;
52
60
  wheelScrollLines;
53
61
  mouseEnabled;
62
+ searchMatchStyle;
63
+ searchCurrentMatchStyle;
54
64
  openUrl;
65
+ onRightClickPaste;
66
+ copySelection;
55
67
  constructor(terminal, showHardwareCursor, logDirectory, options = {}) {
56
68
  super(terminal, showHardwareCursor, logDirectory);
57
69
  this.implicitDocument = {
@@ -65,7 +77,11 @@ export class TuiAltScreen extends TuiBase {
65
77
  this.flashes = new AltScreenFlashContainer(() => this.requestRender());
66
78
  this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1));
67
79
  this.mouseEnabled = options.mouse ?? true;
80
+ this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`);
81
+ this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`);
68
82
  this.openUrl = options.openUrl;
83
+ this.onRightClickPaste = options.onRightClickPaste;
84
+ this.copySelection = options.copySelection;
69
85
  this.addInputListener((data) => this.handleViewportInput(data));
70
86
  }
71
87
  get viewportTop() {
@@ -108,12 +124,26 @@ export class TuiAltScreen extends TuiBase {
108
124
  this.lastDocument = [];
109
125
  this.selectionAnchor = undefined;
110
126
  this.selectionFocus = undefined;
127
+ this.selectionGranularity = "character";
128
+ this.selectionInitialRange = undefined;
129
+ this.lastClick = undefined;
111
130
  this.pressedUrl = undefined;
112
131
  this.selectionDragged = false;
113
132
  this.resetRenderState();
114
- this.terminal.write(`${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? ENABLE_MOUSE : ""}\x1b[2J\x1b[H\x1b[?25l`);
133
+ const term = process.env.TERM?.toLowerCase() ?? "";
134
+ // Multiplexers can lag when every pointer movement is forwarded. Button-motion
135
+ // tracking preserves clicks, wheel events, selections, and scrollbar dragging.
136
+ const mouseSequence = process.env.TMUX !== undefined ||
137
+ process.env.ZELLIJ !== undefined ||
138
+ process.env.STY !== undefined ||
139
+ term.startsWith("tmux") ||
140
+ term.startsWith("screen")
141
+ ? ENABLE_BUTTON_MOTION_MOUSE
142
+ : ENABLE_ALL_MOTION_MOUSE;
143
+ this.terminal.write(`${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`);
115
144
  }
116
145
  beforeTerminalStop(_options) {
146
+ this.closeSearch();
117
147
  this.stopSelectionAutoScroll();
118
148
  this.selectionPressActive = false;
119
149
  this.stopScrollbarHover();
@@ -232,13 +262,126 @@ export class TuiAltScreen extends TuiBase {
232
262
  return;
233
263
  }
234
264
  }
265
+ openSearch() {
266
+ if (this.activeSearch) {
267
+ this.activeSearch.overlay?.focus();
268
+ return;
269
+ }
270
+ const component = new AltScreenSearchComponent((query) => this.updateSearchQuery(query));
271
+ const search = {
272
+ component,
273
+ query: "",
274
+ matches: [],
275
+ selectedIndex: -1,
276
+ anchorRow: this.getPrimaryScrollView().scrollTop,
277
+ selectionMode: "query",
278
+ };
279
+ this.activeSearch = search;
280
+ search.overlay = this.showOverlay(component, {
281
+ anchor: "top-right",
282
+ width: "40%",
283
+ minWidth: 24,
284
+ margin: 1,
285
+ });
286
+ }
287
+ closeSearch() {
288
+ const search = this.activeSearch;
289
+ if (!search)
290
+ return;
291
+ this.activeSearch = undefined;
292
+ search.overlay?.hide();
293
+ this.requestRender();
294
+ }
295
+ updateSearchQuery(query) {
296
+ const search = this.activeSearch;
297
+ if (!search || query === search.query)
298
+ return;
299
+ const selected = search.matches[search.selectedIndex];
300
+ search.anchorRow = selected?.segments[0]?.row ?? this.getPrimaryScrollView().scrollTop;
301
+ search.query = query;
302
+ search.selectionMode = "query";
303
+ search.component.setResult(-1, 0);
304
+ this.requestRender();
305
+ }
306
+ navigateSearch(direction) {
307
+ const search = this.activeSearch;
308
+ if (!search?.query)
309
+ return;
310
+ search.selectionMode = direction < 0 ? "previous" : "next";
311
+ this.requestRender();
312
+ }
313
+ refreshSearch(layout) {
314
+ const search = this.activeSearch;
315
+ if (!search)
316
+ return false;
317
+ const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
318
+ const box = getScrollViewBox(layout, scrollView);
319
+ const lines = box?.scrollContentLines;
320
+ if (!lines || !search.query.trim()) {
321
+ search.matches = [];
322
+ search.selectedIndex = -1;
323
+ search.selectedKey = undefined;
324
+ search.selectionMode = "retain";
325
+ search.component.setResult(-1, 0);
326
+ return false;
327
+ }
328
+ 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;
333
+ let selectedIndex = -1;
334
+ if (matches.length > 0) {
335
+ if (search.selectionMode === "query") {
336
+ selectedIndex = matches.findIndex((match) => (match.segments[0]?.row ?? 0) >= search.anchorRow);
337
+ if (selectedIndex < 0)
338
+ selectedIndex = 0;
339
+ }
340
+ else if (search.selectionMode === "next") {
341
+ const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1);
342
+ selectedIndex = baseIndex < 0 ? 0 : (baseIndex + 1) % matches.length;
343
+ }
344
+ else if (search.selectionMode === "previous") {
345
+ const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1);
346
+ selectedIndex = baseIndex < 0 ? matches.length - 1 : (baseIndex - 1 + matches.length) % matches.length;
347
+ }
348
+ else {
349
+ selectedIndex =
350
+ exactIndex >= 0 ? exactIndex : Math.min(Math.max(0, search.selectedIndex), matches.length - 1);
351
+ }
352
+ }
353
+ search.matches = matches;
354
+ search.selectedIndex = selectedIndex;
355
+ search.selectedKey = selectedIndex >= 0 ? getAltScreenSearchMatchKey(matches[selectedIndex]) : undefined;
356
+ search.selectionMode = "retain";
357
+ search.component.setResult(selectedIndex, matches.length);
358
+ if (!shouldRevealSelection)
359
+ return false;
360
+ const selected = matches[selectedIndex];
361
+ const firstSegment = selected?.segments[0];
362
+ const lastSegment = selected?.segments[selected.segments.length - 1];
363
+ if (!box || !firstSegment || !lastSegment || scrollView.viewportHeight <= 0)
364
+ return false;
365
+ const before = scrollView.scrollTop;
366
+ const visibleBottom = before + scrollView.viewportHeight - 1;
367
+ let target = before;
368
+ if (firstSegment.row < before || lastSegment.row > visibleBottom) {
369
+ target = firstSegment.row - Math.floor(scrollView.viewportHeight / 3);
370
+ }
371
+ scrollView.scrollTo(target, { disableFollow: true });
372
+ return scrollView.scrollTop !== before;
373
+ }
235
374
  /** Show a transient message in the alternate-screen flash stack. */
236
375
  flash(message, durationMs) {
237
376
  this.flashes.flash(message, durationMs);
238
377
  }
378
+ shouldDeferViewportInputToOverlay() {
379
+ return this.isOverlayFocused() && this.activeSearch?.overlay?.isFocused() !== true;
380
+ }
239
381
  handleViewportInput(data) {
240
382
  if (data === FOCUS_OUT) {
241
383
  const hadActiveSelection = this.selectionPressActive;
384
+ const hadNonEmptyActiveSelection = hadActiveSelection && this.getSelectionBounds() !== undefined;
242
385
  this.selectionPressActive = false;
243
386
  this.stopSelectionAutoScroll();
244
387
  this.stopScrollbarHover();
@@ -248,19 +391,27 @@ export class TuiAltScreen extends TuiBase {
248
391
  if (hadActiveSelection) {
249
392
  this.selectionAnchor = undefined;
250
393
  this.selectionFocus = undefined;
394
+ this.selectionGranularity = "character";
395
+ this.selectionInitialRange = undefined;
396
+ if (hadNonEmptyActiveSelection)
397
+ this.requestRender();
251
398
  }
252
- this.requestRender();
399
+ this.lastClick = undefined;
253
400
  return { consume: true };
254
401
  }
255
402
  if (data === FOCUS_IN)
256
403
  return { consume: true };
257
404
  const wheelEvent = this.parseWheelEvent(data);
258
405
  if (wheelEvent) {
406
+ if (this.shouldDeferViewportInputToOverlay())
407
+ return undefined;
259
408
  this.routeWheel(wheelEvent);
260
409
  return { consume: true };
261
410
  }
262
411
  const mouseEvent = this.parseSgrMouseEvent(data);
263
412
  if (mouseEvent) {
413
+ if (this.handleRightClickPaste(mouseEvent))
414
+ return { consume: true };
264
415
  const handled = this.handleScrollbarMouseEvent(mouseEvent);
265
416
  if (!this.scrollbarDrag)
266
417
  this.updateScrollbarHover(mouseEvent.x, mouseEvent.y);
@@ -272,6 +423,30 @@ export class TuiAltScreen extends TuiBase {
272
423
  return { consume: true };
273
424
  const keybindings = getKeybindings();
274
425
  const isRelease = isKeyRelease(data);
426
+ if (keybindings.matches(data, "tui.altScreen.search")) {
427
+ if (!isRelease)
428
+ this.openSearch();
429
+ return { consume: true };
430
+ }
431
+ if (this.activeSearch?.overlay?.isFocused()) {
432
+ if (keybindings.matches(data, "tui.altScreen.searchNext")) {
433
+ if (!isRelease)
434
+ this.navigateSearch(1);
435
+ return { consume: true };
436
+ }
437
+ if (keybindings.matches(data, "tui.altScreen.searchPrevious")) {
438
+ if (!isRelease)
439
+ this.navigateSearch(-1);
440
+ return { consume: true };
441
+ }
442
+ if (keybindings.matches(data, "tui.altScreen.searchClose")) {
443
+ if (!isRelease)
444
+ this.closeSearch();
445
+ return { consume: true };
446
+ }
447
+ }
448
+ if (this.shouldDeferViewportInputToOverlay())
449
+ return undefined;
275
450
  if (keybindings.matches(data, "tui.altScreen.pageUp")) {
276
451
  if (!isRelease) {
277
452
  this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP));
@@ -284,6 +459,26 @@ export class TuiAltScreen extends TuiBase {
284
459
  }
285
460
  return { consume: true };
286
461
  }
462
+ if (keybindings.matches(data, "tui.altScreen.halfPageUp")) {
463
+ if (!isRelease)
464
+ this.scrollBy(-Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2)));
465
+ return { consume: true };
466
+ }
467
+ if (keybindings.matches(data, "tui.altScreen.halfPageDown")) {
468
+ if (!isRelease)
469
+ this.scrollBy(Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2)));
470
+ return { consume: true };
471
+ }
472
+ if (keybindings.matches(data, "tui.altScreen.lineUp")) {
473
+ if (!isRelease)
474
+ this.scrollBy(-1);
475
+ return { consume: true };
476
+ }
477
+ if (keybindings.matches(data, "tui.altScreen.lineDown")) {
478
+ if (!isRelease)
479
+ this.scrollBy(1);
480
+ return { consume: true };
481
+ }
287
482
  if (keybindings.matches(data, "tui.altScreen.previousPrompt")) {
288
483
  if (!isRelease)
289
484
  this.scrollToPrompt(-1);
@@ -362,6 +557,18 @@ export class TuiAltScreen extends TuiBase {
362
557
  release: match[4] === "m",
363
558
  };
364
559
  }
560
+ handleRightClickPaste(event) {
561
+ if (!this.onRightClickPaste || process.platform !== "win32" || event.release || event.button !== 2) {
562
+ return false;
563
+ }
564
+ try {
565
+ this.onRightClickPaste();
566
+ }
567
+ catch {
568
+ // Clipboard paste is best-effort.
569
+ }
570
+ return true;
571
+ }
365
572
  getScrollbarTargetAt(x, y) {
366
573
  if (this.hasOverlay() || !this.currentLayout)
367
574
  return undefined;
@@ -417,6 +624,9 @@ export class TuiAltScreen extends TuiBase {
417
624
  this.selectionPressActive = false;
418
625
  this.selectionAnchor = undefined;
419
626
  this.selectionFocus = undefined;
627
+ this.selectionGranularity = "character";
628
+ this.selectionInitialRange = undefined;
629
+ this.lastClick = undefined;
420
630
  this.pressedUrl = undefined;
421
631
  this.selectionDragged = false;
422
632
  this.setScrollbarHover(target.scrollView);
@@ -458,6 +668,79 @@ export class TuiAltScreen extends TuiBase {
458
668
  col: Math.max(0, Math.min(this.terminal.columns - 1, event.x)),
459
669
  };
460
670
  }
671
+ getSelectionSourceLine(point) {
672
+ if (point.scrollView && this.currentLayout) {
673
+ const lines = getScrollViewBox(this.currentLayout, point.scrollView)?.scrollContentLines;
674
+ if (lines)
675
+ return lines[point.row] ?? "";
676
+ }
677
+ return this.previousScreen[point.row] ?? "";
678
+ }
679
+ getWordSelection(point) {
680
+ const line = stripTerminalSequences(this.getSelectionSourceLine(point));
681
+ let start = 0;
682
+ for (const segment of wordSegmenter.segment(line)) {
683
+ const end = start + visibleWidth(segment.segment);
684
+ if (point.col >= start && point.col < end) {
685
+ return {
686
+ start: { ...point, col: start },
687
+ end: { ...point, col: end, boundary: true },
688
+ };
689
+ }
690
+ start = end;
691
+ }
692
+ return undefined;
693
+ }
694
+ getLineSelection(point) {
695
+ return {
696
+ start: { ...point, col: 0 },
697
+ end: { ...point, col: visibleWidth(this.getSelectionSourceLine(point)), boundary: true },
698
+ };
699
+ }
700
+ updateSelectionFocus(point) {
701
+ if (this.selectionGranularity === "character" || !this.selectionInitialRange) {
702
+ this.selectionFocus = point;
703
+ return;
704
+ }
705
+ const range = this.selectionGranularity === "word" ? this.getWordSelection(point) : this.getLineSelection(point);
706
+ if (!range)
707
+ return;
708
+ const initial = this.selectionInitialRange;
709
+ const targetBeforeInitial = range.start.row < initial.start.row ||
710
+ (range.start.row === initial.start.row && range.start.col < initial.start.col);
711
+ if (targetBeforeInitial) {
712
+ this.selectionAnchor = initial.end;
713
+ this.selectionFocus = range.start;
714
+ }
715
+ else {
716
+ this.selectionAnchor = initial.start;
717
+ this.selectionFocus = range.end;
718
+ }
719
+ }
720
+ getClickCount(point, word) {
721
+ const now = Date.now();
722
+ const previous = this.lastClick;
723
+ const count = word &&
724
+ previous &&
725
+ now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS &&
726
+ previous.row === point.row &&
727
+ previous.scrollView === point.scrollView &&
728
+ previous.wordStart === word.start.col &&
729
+ previous.wordEnd === word.end.col
730
+ ? (previous.count % 3) + 1
731
+ : 1;
732
+ this.lastClick = word
733
+ ? {
734
+ timestamp: now,
735
+ count,
736
+ row: point.row,
737
+ scrollView: point.scrollView,
738
+ wordStart: word.start.col,
739
+ wordEnd: word.end.col,
740
+ }
741
+ : undefined;
742
+ return count;
743
+ }
461
744
  updateSelectionAutoScroll(event) {
462
745
  const scrollView = this.selectionAnchor?.scrollView;
463
746
  if (!scrollView || !this.currentLayout) {
@@ -497,7 +780,7 @@ export class TuiAltScreen extends TuiBase {
497
780
  }
498
781
  const point = this.getScrollSelectionPoint(scrollView, pointer.x, pointer.y);
499
782
  if (point)
500
- this.selectionFocus = point;
783
+ this.updateSelectionFocus(point);
501
784
  this.requestRender();
502
785
  }
503
786
  stopSelectionAutoScroll() {
@@ -509,7 +792,8 @@ export class TuiAltScreen extends TuiBase {
509
792
  this.selectionDragPointer = undefined;
510
793
  }
511
794
  handleSelectionMouseEvent(event) {
512
- if ((event.button & 3) !== 0)
795
+ const button = event.button & 3;
796
+ if (button !== 0 && !(event.release && button === 3))
513
797
  return;
514
798
  const anchorScrollView = this.selectionAnchor?.scrollView;
515
799
  const point = this.getSelectionPoint(event, anchorScrollView);
@@ -520,7 +804,7 @@ export class TuiAltScreen extends TuiBase {
520
804
  this.stopSelectionAutoScroll();
521
805
  if (!this.selectionAnchor)
522
806
  return;
523
- this.selectionFocus = point;
807
+ this.updateSelectionFocus(point);
524
808
  const clickedUrl = !this.selectionDragged &&
525
809
  this.selectionAnchor.scrollView === point.scrollView &&
526
810
  this.selectionAnchor.row === point.row &&
@@ -540,7 +824,7 @@ export class TuiAltScreen extends TuiBase {
540
824
  this.requestRender();
541
825
  return;
542
826
  }
543
- this.copySelectionToClipboard();
827
+ void this.copySelectionToClipboard();
544
828
  this.requestRender();
545
829
  return;
546
830
  }
@@ -548,8 +832,9 @@ export class TuiAltScreen extends TuiBase {
548
832
  if (!this.selectionPressActive || !this.selectionAnchor)
549
833
  return;
550
834
  this.selectionDragged = true;
835
+ this.lastClick = undefined;
551
836
  this.pressedUrl = undefined;
552
- this.selectionFocus = point;
837
+ this.updateSelectionFocus(point);
553
838
  this.updateSelectionAutoScroll(event);
554
839
  this.requestRender();
555
840
  return;
@@ -560,10 +845,17 @@ export class TuiAltScreen extends TuiBase {
560
845
  ? getScrollViewsAt(this.currentLayout, event.x, event.y)[0]
561
846
  : undefined;
562
847
  const anchor = this.getSelectionPoint(event, scrollView);
563
- this.selectionAnchor = anchor;
564
- this.selectionFocus = anchor;
848
+ const word = this.getWordSelection(anchor);
849
+ const clickCount = this.getClickCount(anchor, word);
850
+ const range = clickCount === 2 ? word : clickCount === 3 ? this.getLineSelection(anchor) : undefined;
851
+ this.selectionGranularity = range ? (clickCount === 2 ? "word" : "line") : "character";
852
+ this.selectionInitialRange = range;
853
+ this.selectionAnchor = range?.start ?? anchor;
854
+ this.selectionFocus = range?.end ?? anchor;
565
855
  this.selectionDragged = false;
566
- this.pressedUrl = getOsc8LinkAtColumn(this.previousScreen[Math.max(0, Math.min(this.terminal.rows - 1, event.y))] ?? "", Math.max(0, Math.min(this.terminal.columns - 1, event.x)));
856
+ this.pressedUrl = range
857
+ ? undefined
858
+ : getOsc8LinkAtColumn(this.previousScreen[Math.max(0, Math.min(this.terminal.rows - 1, event.y))] ?? "", Math.max(0, Math.min(this.terminal.columns - 1, event.x)));
567
859
  this.requestRender();
568
860
  }
569
861
  getSelectionBounds() {
@@ -589,11 +881,13 @@ export class TuiAltScreen extends TuiBase {
589
881
  start = getGraphemeCellRange(line, selection.start.col)?.start ?? Math.min(selection.start.col, lineWidth);
590
882
  }
591
883
  if (row === selection.end.row) {
592
- end = getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth);
884
+ end = selection.end.boundary
885
+ ? Math.min(selection.end.col, lineWidth)
886
+ : (getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth));
593
887
  }
594
888
  return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) };
595
889
  }
596
- copySelectionToClipboard() {
890
+ async copySelectionToClipboard() {
597
891
  const selection = this.getSelectionBounds();
598
892
  if (!selection)
599
893
  return;
@@ -615,9 +909,87 @@ export class TuiAltScreen extends TuiBase {
615
909
  const text = lines.join("\n");
616
910
  if (text.length === 0)
617
911
  return;
912
+ // Prefer an injected clipboard implementation (native clipboard + platform tools with a
913
+ // verified success path) when the host app provides one. A bare OSC 52 write can show
914
+ // "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux
915
+ // without OSC 52 clipboard passthrough), so only report success when it actually copies.
916
+ if (this.copySelection) {
917
+ const ok = await this.copySelection(text);
918
+ this.flash(ok ? "Copied!" : "Copy failed");
919
+ return;
920
+ }
618
921
  this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
619
922
  this.flash("Copied!");
620
923
  }
924
+ applySearchTextHighlight(text, current) {
925
+ const style = current ? this.searchCurrentMatchStyle : this.searchMatchStyle;
926
+ let result = "";
927
+ let plainStart = 0;
928
+ let index = 0;
929
+ while (index < text.length) {
930
+ const ansi = extractAnsiCode(text, index);
931
+ if (!ansi) {
932
+ index += 1;
933
+ continue;
934
+ }
935
+ if (index > plainStart)
936
+ result += style(text.slice(plainStart, index));
937
+ result += ansi.code;
938
+ index += ansi.length;
939
+ plainStart = index;
940
+ }
941
+ if (plainStart < text.length)
942
+ result += style(text.slice(plainStart));
943
+ return result;
944
+ }
945
+ applySearchHighlights(screen, layout) {
946
+ const search = this.activeSearch;
947
+ if (!search || search.selectedIndex < 0 || search.matches.length === 0)
948
+ return screen;
949
+ const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
950
+ const box = getScrollViewBox(layout, scrollView);
951
+ if (!box)
952
+ return screen;
953
+ const rangesByRow = new Map();
954
+ const scrollbarColumn = getScrollbarGeometry(box)?.column;
955
+ const minRow = Math.max(0, box.rect.y, box.clip.y);
956
+ const maxRow = Math.min(screen.length, box.rect.y + box.rect.height, box.clip.y + box.clip.height);
957
+ const minColumn = Math.max(0, box.rect.x, box.clip.x);
958
+ const maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width, scrollbarColumn ?? Number.POSITIVE_INFINITY);
959
+ for (let matchIndex = 0; matchIndex < search.matches.length; matchIndex++) {
960
+ for (const segment of search.matches[matchIndex].segments) {
961
+ const row = box.rect.y + segment.row - scrollView.scrollTop;
962
+ if (row < minRow || row >= maxRow)
963
+ continue;
964
+ const startCol = Math.max(minColumn, box.rect.x + segment.startCol);
965
+ const endCol = Math.min(maxColumn, box.rect.x + segment.endCol);
966
+ if (endCol <= startCol)
967
+ continue;
968
+ const ranges = rangesByRow.get(row) ?? [];
969
+ ranges.push({ startCol, endCol, current: matchIndex === search.selectedIndex });
970
+ rangesByRow.set(row, ranges);
971
+ }
972
+ }
973
+ const result = [...screen];
974
+ for (const [row, ranges] of rangesByRow) {
975
+ let line = result[row] ?? "";
976
+ if (isImageLine(line))
977
+ continue;
978
+ const lineWidth = visibleWidth(line);
979
+ for (const range of ranges.sort((a, b) => b.startCol - a.startCol)) {
980
+ const startCol = Math.min(range.startCol, lineWidth);
981
+ const endCol = Math.min(range.endCol, lineWidth);
982
+ if (endCol <= startCol)
983
+ continue;
984
+ const before = sliceByColumn(line, 0, startCol, true);
985
+ const highlighted = sliceByColumn(line, startCol, endCol - startCol, true);
986
+ const after = sliceByColumn(line, endCol, Math.max(0, lineWidth - endCol), true);
987
+ line = `${before}${this.applySearchTextHighlight(highlighted, range.current)}${after}`;
988
+ }
989
+ result[row] = line;
990
+ }
991
+ return result;
992
+ }
621
993
  applySelectionHighlight(text) {
622
994
  let result = "\x1b[7m";
623
995
  let index = 0;
@@ -656,14 +1028,14 @@ export class TuiAltScreen extends TuiBase {
656
1028
  maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width);
657
1029
  screenSelection = {
658
1030
  start: {
1031
+ ...selection.start,
659
1032
  row: box.rect.y + selection.start.row - selection.start.scrollView.scrollTop,
660
1033
  col: box.rect.x + selection.start.col,
661
- scrollView: selection.start.scrollView,
662
1034
  },
663
1035
  end: {
1036
+ ...selection.end,
664
1037
  row: box.rect.y + selection.end.row - selection.start.scrollView.scrollTop,
665
1038
  col: box.rect.x + selection.end.col,
666
- scrollView: selection.start.scrollView,
667
1039
  },
668
1040
  };
669
1041
  }
@@ -710,8 +1082,12 @@ export class TuiAltScreen extends TuiBase {
710
1082
  const width = Math.max(1, this.terminal.columns);
711
1083
  const height = Math.max(1, this.terminal.rows);
712
1084
  const root = this.layoutRoot ?? this.implicitScrollView;
713
- const nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
1085
+ let nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
1086
+ if (this.refreshSearch(nextLayout)) {
1087
+ nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
1088
+ }
714
1089
  let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
1090
+ screen = this.applySearchHighlights(screen, nextLayout);
715
1091
  screen = this.compositeOverlays(screen, width, height);
716
1092
  if (screen.length > height)
717
1093
  screen = screen.slice(screen.length - height);