@earendil-works/pi-tui 0.84.1 → 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";
@@ -53,12 +54,16 @@ export class TuiAltScreen extends TuiBase {
53
54
  selectionPressActive = false;
54
55
  scrollbarDrag;
55
56
  scrollbarHover;
57
+ activeSearch;
56
58
  pressedUrl;
57
59
  selectionDragged = false;
58
60
  wheelScrollLines;
59
61
  mouseEnabled;
62
+ searchMatchStyle;
63
+ searchCurrentMatchStyle;
60
64
  openUrl;
61
65
  onRightClickPaste;
66
+ copySelection;
62
67
  constructor(terminal, showHardwareCursor, logDirectory, options = {}) {
63
68
  super(terminal, showHardwareCursor, logDirectory);
64
69
  this.implicitDocument = {
@@ -72,8 +77,11 @@ export class TuiAltScreen extends TuiBase {
72
77
  this.flashes = new AltScreenFlashContainer(() => this.requestRender());
73
78
  this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1));
74
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`);
75
82
  this.openUrl = options.openUrl;
76
83
  this.onRightClickPaste = options.onRightClickPaste;
84
+ this.copySelection = options.copySelection;
77
85
  this.addInputListener((data) => this.handleViewportInput(data));
78
86
  }
79
87
  get viewportTop() {
@@ -135,6 +143,7 @@ export class TuiAltScreen extends TuiBase {
135
143
  this.terminal.write(`${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`);
136
144
  }
137
145
  beforeTerminalStop(_options) {
146
+ this.closeSearch();
138
147
  this.stopSelectionAutoScroll();
139
148
  this.selectionPressActive = false;
140
149
  this.stopScrollbarHover();
@@ -253,13 +262,126 @@ export class TuiAltScreen extends TuiBase {
253
262
  return;
254
263
  }
255
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
+ }
256
374
  /** Show a transient message in the alternate-screen flash stack. */
257
375
  flash(message, durationMs) {
258
376
  this.flashes.flash(message, durationMs);
259
377
  }
378
+ shouldDeferViewportInputToOverlay() {
379
+ return this.isOverlayFocused() && this.activeSearch?.overlay?.isFocused() !== true;
380
+ }
260
381
  handleViewportInput(data) {
261
382
  if (data === FOCUS_OUT) {
262
383
  const hadActiveSelection = this.selectionPressActive;
384
+ const hadNonEmptyActiveSelection = hadActiveSelection && this.getSelectionBounds() !== undefined;
263
385
  this.selectionPressActive = false;
264
386
  this.stopSelectionAutoScroll();
265
387
  this.stopScrollbarHover();
@@ -271,15 +393,18 @@ export class TuiAltScreen extends TuiBase {
271
393
  this.selectionFocus = undefined;
272
394
  this.selectionGranularity = "character";
273
395
  this.selectionInitialRange = undefined;
396
+ if (hadNonEmptyActiveSelection)
397
+ this.requestRender();
274
398
  }
275
399
  this.lastClick = undefined;
276
- this.requestRender();
277
400
  return { consume: true };
278
401
  }
279
402
  if (data === FOCUS_IN)
280
403
  return { consume: true };
281
404
  const wheelEvent = this.parseWheelEvent(data);
282
405
  if (wheelEvent) {
406
+ if (this.shouldDeferViewportInputToOverlay())
407
+ return undefined;
283
408
  this.routeWheel(wheelEvent);
284
409
  return { consume: true };
285
410
  }
@@ -298,6 +423,30 @@ export class TuiAltScreen extends TuiBase {
298
423
  return { consume: true };
299
424
  const keybindings = getKeybindings();
300
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;
301
450
  if (keybindings.matches(data, "tui.altScreen.pageUp")) {
302
451
  if (!isRelease) {
303
452
  this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP));
@@ -320,6 +469,16 @@ export class TuiAltScreen extends TuiBase {
320
469
  this.scrollBy(Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2)));
321
470
  return { consume: true };
322
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
+ }
323
482
  if (keybindings.matches(data, "tui.altScreen.previousPrompt")) {
324
483
  if (!isRelease)
325
484
  this.scrollToPrompt(-1);
@@ -633,7 +792,8 @@ export class TuiAltScreen extends TuiBase {
633
792
  this.selectionDragPointer = undefined;
634
793
  }
635
794
  handleSelectionMouseEvent(event) {
636
- if ((event.button & 3) !== 0)
795
+ const button = event.button & 3;
796
+ if (button !== 0 && !(event.release && button === 3))
637
797
  return;
638
798
  const anchorScrollView = this.selectionAnchor?.scrollView;
639
799
  const point = this.getSelectionPoint(event, anchorScrollView);
@@ -664,7 +824,7 @@ export class TuiAltScreen extends TuiBase {
664
824
  this.requestRender();
665
825
  return;
666
826
  }
667
- this.copySelectionToClipboard();
827
+ void this.copySelectionToClipboard();
668
828
  this.requestRender();
669
829
  return;
670
830
  }
@@ -727,7 +887,7 @@ export class TuiAltScreen extends TuiBase {
727
887
  }
728
888
  return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) };
729
889
  }
730
- copySelectionToClipboard() {
890
+ async copySelectionToClipboard() {
731
891
  const selection = this.getSelectionBounds();
732
892
  if (!selection)
733
893
  return;
@@ -749,9 +909,87 @@ export class TuiAltScreen extends TuiBase {
749
909
  const text = lines.join("\n");
750
910
  if (text.length === 0)
751
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
+ }
752
921
  this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
753
922
  this.flash("Copied!");
754
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
+ }
755
993
  applySelectionHighlight(text) {
756
994
  let result = "\x1b[7m";
757
995
  let index = 0;
@@ -844,8 +1082,12 @@ export class TuiAltScreen extends TuiBase {
844
1082
  const width = Math.max(1, this.terminal.columns);
845
1083
  const height = Math.max(1, this.terminal.rows);
846
1084
  const root = this.layoutRoot ?? this.implicitScrollView;
847
- 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
+ }
848
1089
  let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
1090
+ screen = this.applySearchHighlights(screen, nextLayout);
849
1091
  screen = this.compositeOverlays(screen, width, height);
850
1092
  if (screen.length > height)
851
1093
  screen = screen.slice(screen.length - height);