@ashx-j/lunr-tui 0.1.7 → 0.2.0

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 { isKeyRelease, matchesKey } from "./keys.js";
9
9
  import { MOUSE_TRACKING_DISABLE, MOUSE_TRACKING_ENABLE, parseMouseEvent } from "./mouse.js";
10
10
  import { isOsc11BackgroundColorResponse, parseOsc11BackgroundColor, parseTerminalColorSchemeReport, } from "./terminal-colors.js";
11
11
  import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
12
- import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
12
+ import { extractAnsiCode, extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth, } from "./utils.js";
13
13
  const KITTY_SEQUENCE_PREFIX = "\x1b_G";
14
14
  function parseKittyImageHeader(line) {
15
15
  const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
@@ -118,6 +118,8 @@ export class TUI extends Container {
118
118
  inputListeners = new Set();
119
119
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
120
120
  onDebug;
121
+ /** Called with selected user/assistant text after an in-app drag-release. */
122
+ onTextSelected;
121
123
  renderRequested = false;
122
124
  renderTimer;
123
125
  lastRenderAt = 0;
@@ -148,6 +150,9 @@ export class TUI extends Container {
148
150
  alternateScreenActive = false;
149
151
  mouseTrackingActive = false;
150
152
  static WHEEL_LINES = 3;
153
+ chatHitRegions = [];
154
+ chatScrollbar;
155
+ selection;
151
156
  constructor(terminal, showHardwareCursor) {
152
157
  super();
153
158
  this.terminal = terminal;
@@ -376,12 +381,26 @@ export class TUI extends Container {
376
381
  const child = this.children[i];
377
382
  if (!child)
378
383
  continue;
379
- for (const line of child.render(width)) {
380
- lines.push(line);
381
- }
384
+ this.appendRenderedChild(lines, child, width);
382
385
  }
383
386
  return lines;
384
387
  }
388
+ appendRenderedChild(lines, child, width) {
389
+ const nested = "children" in child && Array.isArray(child.children);
390
+ if (nested && child.children.length > 0 && child.render === Container.prototype.render) {
391
+ for (const nestedChild of child.children) {
392
+ this.appendRenderedChild(lines, nestedChild, width);
393
+ }
394
+ return;
395
+ }
396
+ const start = lines.length;
397
+ for (const line of child.render(width)) {
398
+ lines.push(line);
399
+ }
400
+ if (child.selectable && lines.length > start) {
401
+ this.chatHitRegions.push({ y0: start, y1: lines.length - 1, lines: lines.slice(start) });
402
+ }
403
+ }
385
404
  snapStartToKittyImageHeader(lines, start) {
386
405
  if (start <= 0 || start >= lines.length)
387
406
  return start;
@@ -403,11 +422,12 @@ export class TUI extends Container {
403
422
  render(width) {
404
423
  const pinIndex = this.getPinIndex();
405
424
  if (pinIndex < 0) {
425
+ this.chatHitRegions = [];
426
+ this.chatScrollbar = undefined;
406
427
  return super.render(width);
407
428
  }
408
429
  const rows = Math.max(1, this.terminal.rows);
409
430
  const minView = 1;
410
- const scrollLines = this.collectChildLines(0, pinIndex, width);
411
431
  let dockLines = this.collectChildLines(pinIndex, this.children.length, width);
412
432
  const maxDock = rows - minView;
413
433
  if (dockLines.length > maxDock) {
@@ -415,26 +435,66 @@ export class TUI extends Container {
415
435
  }
416
436
  const viewH = Math.max(minView, rows - dockLines.length);
417
437
  this.lastChatViewportHeight = viewH;
438
+ this.chatHitRegions = [];
439
+ const chatWidthProbe = this.collectChildLines(0, pinIndex, width);
440
+ const showScrollbar = chatWidthProbe.length > viewH;
441
+ const chatWidth = showScrollbar ? Math.max(1, width - 1) : width;
442
+ let scrollLines = chatWidthProbe;
443
+ if (showScrollbar) {
444
+ this.chatHitRegions = [];
445
+ scrollLines = this.collectChildLines(0, pinIndex, chatWidth);
446
+ }
418
447
  let chatSlice;
448
+ let start = 0;
419
449
  if (scrollLines.length <= viewH) {
420
450
  // Content-hug: do not pin the dock to the bottom of an unfilled screen.
421
451
  this.chatScrollOffset = 0;
422
452
  this.lastChatScrollMax = 0;
453
+ this.chatScrollbar = undefined;
423
454
  chatSlice = scrollLines;
424
455
  }
425
456
  else {
426
457
  this.lastChatScrollMax = scrollLines.length - viewH;
427
458
  this.chatScrollOffset = Math.max(0, Math.min(this.chatScrollOffset, this.lastChatScrollMax));
428
- let start = scrollLines.length - viewH - this.chatScrollOffset;
459
+ start = scrollLines.length - viewH - this.chatScrollOffset;
429
460
  start = this.snapStartToKittyImageHeader(scrollLines, start);
430
461
  start = Math.max(0, start);
431
462
  chatSlice = scrollLines.slice(start, start + viewH);
432
463
  while (chatSlice.length < viewH) {
433
464
  chatSlice.push("");
434
465
  }
466
+ this.chatScrollbar = { x: width, y0: 1, y1: viewH, viewH, scrollMax: this.lastChatScrollMax };
467
+ const thumbH = Math.max(1, Math.round((viewH * viewH) / scrollLines.length));
468
+ const travel = Math.max(0, viewH - thumbH);
469
+ const thumbTop = this.lastChatScrollMax === 0
470
+ ? viewH - thumbH
471
+ : Math.round(travel * (1 - this.chatScrollOffset / this.lastChatScrollMax));
472
+ const track = "\x1b[2m│\x1b[22m";
473
+ const thumb = "\x1b[1m█\x1b[22m";
474
+ for (let i = 0; i < chatSlice.length; i++) {
475
+ const glyph = i >= thumbTop && i < thumbTop + thumbH ? thumb : track;
476
+ chatSlice[i] = `${padToWidth(chatSlice[i] ?? "", chatWidth)}${glyph}`;
477
+ }
478
+ }
479
+ this.shiftHitRegions(-start);
480
+ if (this.selection) {
481
+ const lo = Math.min(this.selection.anchorY, this.selection.focusY);
482
+ const hi = Math.max(this.selection.anchorY, this.selection.focusY);
483
+ for (let y = lo; y <= hi; y++) {
484
+ const idx = y - 1;
485
+ if (idx < 0 || idx >= chatSlice.length)
486
+ continue;
487
+ chatSlice[idx] = applySelectionHighlight(chatSlice[idx] ?? "", showScrollbar ? chatWidth : width);
488
+ }
435
489
  }
436
490
  return [...chatSlice, ...dockLines];
437
491
  }
492
+ shiftHitRegions(delta) {
493
+ for (const region of this.chatHitRegions) {
494
+ region.y0 += delta;
495
+ region.y1 += delta;
496
+ }
497
+ }
438
498
  /**
439
499
  * Show an overlay component with configurable positioning and sizing.
440
500
  * Returns a handle to control the overlay's visibility.
@@ -731,14 +791,75 @@ export class TUI extends Container {
731
791
  this.mouseTrackingActive = false;
732
792
  }
733
793
  handleMouseEvent(mouse) {
734
- if (mouse.kind !== "wheel" || mouse.delta === 0)
735
- return;
736
794
  if (!this.pinComponent || this.getPinIndex() < 0)
737
795
  return;
738
796
  if (this.hasCapturingOverlayFocus())
739
797
  return;
740
- const amount = mouse.ctrl ? this.getChatViewportHeight() : TUI.WHEEL_LINES;
741
- this.scrollChat(mouse.delta * amount);
798
+ if (mouse.kind === "wheel") {
799
+ if (mouse.delta === 0)
800
+ return;
801
+ const amount = mouse.ctrl ? this.getChatViewportHeight() : TUI.WHEEL_LINES;
802
+ this.scrollChat(mouse.delta * amount);
803
+ return;
804
+ }
805
+ // Shift+drag stays with the terminal's native selection.
806
+ if (mouse.shift)
807
+ return;
808
+ if (mouse.kind === "button" && !mouse.release && this.chatScrollbar && mouse.x === this.chatScrollbar.x) {
809
+ const viewH = this.chatScrollbar.viewH;
810
+ const y = Math.max(this.chatScrollbar.y0, Math.min(mouse.y, this.chatScrollbar.y1));
811
+ const rel = (y - this.chatScrollbar.y0) / Math.max(1, viewH - 1);
812
+ this.setChatScroll(Math.round((1 - rel) * this.chatScrollbar.scrollMax));
813
+ this.selection = undefined;
814
+ return;
815
+ }
816
+ if (mouse.kind === "button" && !mouse.release) {
817
+ const region = this.hitSelectable(mouse.y);
818
+ if (!region) {
819
+ this.selection = undefined;
820
+ return;
821
+ }
822
+ this.selection = { anchorY: mouse.y, focusY: mouse.y };
823
+ this.requestRender();
824
+ return;
825
+ }
826
+ if (!this.selection)
827
+ return;
828
+ if (mouse.kind === "move") {
829
+ this.selection.focusY = mouse.y;
830
+ this.requestRender();
831
+ return;
832
+ }
833
+ if (mouse.kind === "button" && mouse.release) {
834
+ this.selection.focusY = mouse.y;
835
+ const text = this.selectedText();
836
+ this.selection = undefined;
837
+ this.requestRender();
838
+ if (text)
839
+ this.onTextSelected?.(text);
840
+ }
841
+ }
842
+ hitSelectable(y) {
843
+ return this.chatHitRegions.find((region) => y >= region.y0 + 1 && y <= region.y1 + 1);
844
+ }
845
+ selectedText() {
846
+ if (!this.selection)
847
+ return undefined;
848
+ const lo = Math.min(this.selection.anchorY, this.selection.focusY);
849
+ const hi = Math.max(this.selection.anchorY, this.selection.focusY);
850
+ if (lo === hi)
851
+ return undefined;
852
+ const lines = [];
853
+ for (const region of this.chatHitRegions) {
854
+ for (let i = 0; i < region.lines.length; i++) {
855
+ const y = region.y0 + i + 1;
856
+ if (y < lo || y > hi)
857
+ continue;
858
+ lines.push(stripAnsiForCopy(region.lines[i] ?? ""));
859
+ }
860
+ }
861
+ const text = lines.join("\n").trim();
862
+ return text.length > 0 ? text : undefined;
742
863
  }
743
864
  handleInput(data) {
744
865
  if (this.consumeOsc11BackgroundResponse(data)) {
@@ -1609,4 +1730,28 @@ export class TUI extends Container {
1609
1730
  });
1610
1731
  }
1611
1732
  }
1733
+ function padToWidth(line, width) {
1734
+ const vis = visibleWidth(line);
1735
+ if (vis >= width)
1736
+ return line;
1737
+ return line + " ".repeat(width - vis);
1738
+ }
1739
+ function applySelectionHighlight(line, width) {
1740
+ const padded = padToWidth(line, width);
1741
+ return `\x1b[7m${padded}\x1b[27m`;
1742
+ }
1743
+ function stripAnsiForCopy(line) {
1744
+ let out = "";
1745
+ let i = 0;
1746
+ while (i < line.length) {
1747
+ const ansi = extractAnsiCode(line, i);
1748
+ if (ansi) {
1749
+ i += ansi.length;
1750
+ continue;
1751
+ }
1752
+ out += line[i];
1753
+ i++;
1754
+ }
1755
+ return out.replace(/\s+$/, "");
1756
+ }
1612
1757
  //# sourceMappingURL=tui.js.map