@ashx-j/lunr-tui 0.1.7 → 0.2.1

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;
@@ -144,10 +146,18 @@ export class TUI extends Container {
144
146
  chatScrollOffset = 0;
145
147
  lastChatViewportHeight = 1;
146
148
  lastChatScrollMax = 0;
149
+ chatLaidOut = false;
150
+ chatUsesGutter = false;
151
+ /** Bumped on non-scroll `requestRender` so offset-only paints can reuse chat layout. */
152
+ contentEpoch = 0;
153
+ chatLayoutCache;
147
154
  alternateScreen = false;
148
155
  alternateScreenActive = false;
149
156
  mouseTrackingActive = false;
150
157
  static WHEEL_LINES = 3;
158
+ chatHitRegions = [];
159
+ chatScrollbar;
160
+ selection;
151
161
  constructor(terminal, showHardwareCursor) {
152
162
  super();
153
163
  this.terminal = terminal;
@@ -187,6 +197,9 @@ export class TUI extends Container {
187
197
  */
188
198
  pinFrom(component) {
189
199
  this.pinComponent = component;
200
+ this.chatLayoutCache = undefined;
201
+ this.chatUsesGutter = false;
202
+ this.chatLaidOut = false;
190
203
  if (!component) {
191
204
  this.chatScrollOffset = 0;
192
205
  this.lastChatViewportHeight = Math.max(1, this.terminal.rows);
@@ -202,16 +215,20 @@ export class TUI extends Container {
202
215
  /** `0` follows the latest chat. Larger values scroll up into history. */
203
216
  setChatScroll(offset) {
204
217
  const next = Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0;
205
- this.chatScrollOffset = next;
206
- if (this.getPinIndex() >= 0) {
207
- this.render(this.terminal.columns);
208
- }
209
- else {
218
+ if (this.getPinIndex() < 0) {
219
+ const changed = this.chatScrollOffset !== 0 || this.lastChatScrollMax !== 0;
210
220
  this.chatScrollOffset = 0;
211
221
  this.lastChatScrollMax = 0;
222
+ if (changed && this.started)
223
+ this.requestRender();
224
+ return;
212
225
  }
226
+ const clamped = this.chatLaidOut ? Math.min(next, this.lastChatScrollMax) : next;
227
+ if (clamped === this.chatScrollOffset)
228
+ return;
229
+ this.chatScrollOffset = clamped;
213
230
  if (this.started)
214
- this.requestRender();
231
+ this.requestRenderFromScroll();
215
232
  }
216
233
  /** Positive delta scrolls up (older). Negative scrolls toward the latest. */
217
234
  scrollChat(deltaLines) {
@@ -376,12 +393,26 @@ export class TUI extends Container {
376
393
  const child = this.children[i];
377
394
  if (!child)
378
395
  continue;
379
- for (const line of child.render(width)) {
380
- lines.push(line);
381
- }
396
+ this.appendRenderedChild(lines, child, width);
382
397
  }
383
398
  return lines;
384
399
  }
400
+ appendRenderedChild(lines, child, width) {
401
+ const nested = "children" in child && Array.isArray(child.children);
402
+ if (nested && child.children.length > 0 && child.render === Container.prototype.render) {
403
+ for (const nestedChild of child.children) {
404
+ this.appendRenderedChild(lines, nestedChild, width);
405
+ }
406
+ return;
407
+ }
408
+ const start = lines.length;
409
+ for (const line of child.render(width)) {
410
+ lines.push(line);
411
+ }
412
+ if (child.selectable && lines.length > start) {
413
+ this.chatHitRegions.push({ y0: start, y1: lines.length - 1, lines: lines.slice(start) });
414
+ }
415
+ }
385
416
  snapStartToKittyImageHeader(lines, start) {
386
417
  if (start <= 0 || start >= lines.length)
387
418
  return start;
@@ -403,11 +434,15 @@ export class TUI extends Container {
403
434
  render(width) {
404
435
  const pinIndex = this.getPinIndex();
405
436
  if (pinIndex < 0) {
437
+ this.chatHitRegions = [];
438
+ this.chatScrollbar = undefined;
439
+ this.chatLayoutCache = undefined;
440
+ this.chatUsesGutter = false;
441
+ this.chatLaidOut = false;
406
442
  return super.render(width);
407
443
  }
408
444
  const rows = Math.max(1, this.terminal.rows);
409
445
  const minView = 1;
410
- const scrollLines = this.collectChildLines(0, pinIndex, width);
411
446
  let dockLines = this.collectChildLines(pinIndex, this.children.length, width);
412
447
  const maxDock = rows - minView;
413
448
  if (dockLines.length > maxDock) {
@@ -415,26 +450,99 @@ export class TUI extends Container {
415
450
  }
416
451
  const viewH = Math.max(minView, rows - dockLines.length);
417
452
  this.lastChatViewportHeight = viewH;
453
+ this.chatLaidOut = true;
454
+ let chatWidth = this.chatUsesGutter ? Math.max(1, width - 1) : width;
455
+ let scrollLines;
456
+ let fromCache = false;
457
+ const cache = this.chatLayoutCache;
458
+ if (cache && cache.epoch === this.contentEpoch && cache.chatWidth === chatWidth) {
459
+ scrollLines = cache.scrollLines;
460
+ this.chatHitRegions = this.copyHitRegions(cache.hitRegionsUnshifted);
461
+ fromCache = true;
462
+ }
463
+ else {
464
+ this.chatHitRegions = [];
465
+ scrollLines = this.collectChildLines(0, pinIndex, chatWidth);
466
+ }
467
+ if (this.chatUsesGutter) {
468
+ if (scrollLines.length <= viewH) {
469
+ this.chatUsesGutter = false;
470
+ chatWidth = width;
471
+ this.chatHitRegions = [];
472
+ scrollLines = this.collectChildLines(0, pinIndex, chatWidth);
473
+ fromCache = false;
474
+ }
475
+ }
476
+ else if (scrollLines.length > viewH) {
477
+ this.chatUsesGutter = true;
478
+ chatWidth = Math.max(1, width - 1);
479
+ this.chatHitRegions = [];
480
+ scrollLines = this.collectChildLines(0, pinIndex, chatWidth);
481
+ fromCache = false;
482
+ }
483
+ if (!fromCache) {
484
+ this.chatLayoutCache = {
485
+ epoch: this.contentEpoch,
486
+ chatWidth,
487
+ scrollLines,
488
+ hitRegionsUnshifted: this.copyHitRegions(this.chatHitRegions),
489
+ };
490
+ }
418
491
  let chatSlice;
492
+ let start = 0;
419
493
  if (scrollLines.length <= viewH) {
420
494
  // Content-hug: do not pin the dock to the bottom of an unfilled screen.
421
495
  this.chatScrollOffset = 0;
422
496
  this.lastChatScrollMax = 0;
423
- chatSlice = scrollLines;
497
+ this.chatScrollbar = undefined;
498
+ chatSlice = scrollLines.slice();
424
499
  }
425
500
  else {
426
501
  this.lastChatScrollMax = scrollLines.length - viewH;
427
502
  this.chatScrollOffset = Math.max(0, Math.min(this.chatScrollOffset, this.lastChatScrollMax));
428
- let start = scrollLines.length - viewH - this.chatScrollOffset;
503
+ start = scrollLines.length - viewH - this.chatScrollOffset;
429
504
  start = this.snapStartToKittyImageHeader(scrollLines, start);
430
505
  start = Math.max(0, start);
431
506
  chatSlice = scrollLines.slice(start, start + viewH);
432
507
  while (chatSlice.length < viewH) {
433
508
  chatSlice.push("");
434
509
  }
510
+ this.chatScrollbar = { x: width, y0: 1, y1: viewH, viewH, scrollMax: this.lastChatScrollMax };
511
+ const thumbH = Math.max(1, Math.round((viewH * viewH) / scrollLines.length));
512
+ const travel = Math.max(0, viewH - thumbH);
513
+ const thumbTop = this.lastChatScrollMax === 0
514
+ ? viewH - thumbH
515
+ : Math.round(travel * (1 - this.chatScrollOffset / this.lastChatScrollMax));
516
+ const track = "\x1b[2m│\x1b[22m";
517
+ const thumb = "\x1b[1m█\x1b[22m";
518
+ for (let i = 0; i < chatSlice.length; i++) {
519
+ const glyph = i >= thumbTop && i < thumbTop + thumbH ? thumb : track;
520
+ chatSlice[i] = `${padToWidth(chatSlice[i] ?? "", chatWidth)}${glyph}`;
521
+ }
522
+ }
523
+ this.shiftHitRegions(-start);
524
+ if (this.selection) {
525
+ const lo = Math.min(this.selection.anchorY, this.selection.focusY);
526
+ const hi = Math.max(this.selection.anchorY, this.selection.focusY);
527
+ const highlightWidth = this.chatUsesGutter ? chatWidth : width;
528
+ for (let y = lo; y <= hi; y++) {
529
+ const idx = y - 1;
530
+ if (idx < 0 || idx >= chatSlice.length)
531
+ continue;
532
+ chatSlice[idx] = applySelectionHighlight(chatSlice[idx] ?? "", highlightWidth);
533
+ }
435
534
  }
436
535
  return [...chatSlice, ...dockLines];
437
536
  }
537
+ copyHitRegions(regions) {
538
+ return regions.map((region) => ({ y0: region.y0, y1: region.y1, lines: region.lines }));
539
+ }
540
+ shiftHitRegions(delta) {
541
+ for (const region of this.chatHitRegions) {
542
+ region.y0 += delta;
543
+ region.y1 += delta;
544
+ }
545
+ }
438
546
  /**
439
547
  * Show an overlay component with configurable positioning and sizing.
440
548
  * Returns a handle to control the overlay's visibility.
@@ -663,6 +771,13 @@ export class TUI extends Container {
663
771
  this.terminal.stop();
664
772
  }
665
773
  requestRender(force = false) {
774
+ this.contentEpoch++;
775
+ this.queueRender(force);
776
+ }
777
+ requestRenderFromScroll() {
778
+ this.queueRender(false);
779
+ }
780
+ queueRender(force) {
666
781
  if (force) {
667
782
  this.previousLines = [];
668
783
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
@@ -731,14 +846,75 @@ export class TUI extends Container {
731
846
  this.mouseTrackingActive = false;
732
847
  }
733
848
  handleMouseEvent(mouse) {
734
- if (mouse.kind !== "wheel" || mouse.delta === 0)
735
- return;
736
849
  if (!this.pinComponent || this.getPinIndex() < 0)
737
850
  return;
738
851
  if (this.hasCapturingOverlayFocus())
739
852
  return;
740
- const amount = mouse.ctrl ? this.getChatViewportHeight() : TUI.WHEEL_LINES;
741
- this.scrollChat(mouse.delta * amount);
853
+ if (mouse.kind === "wheel") {
854
+ if (mouse.delta === 0)
855
+ return;
856
+ const amount = mouse.ctrl ? this.getChatViewportHeight() : TUI.WHEEL_LINES;
857
+ this.scrollChat(mouse.delta * amount);
858
+ return;
859
+ }
860
+ // Shift+drag stays with the terminal's native selection.
861
+ if (mouse.shift)
862
+ return;
863
+ if (mouse.kind === "button" && !mouse.release && this.chatScrollbar && mouse.x === this.chatScrollbar.x) {
864
+ const viewH = this.chatScrollbar.viewH;
865
+ const y = Math.max(this.chatScrollbar.y0, Math.min(mouse.y, this.chatScrollbar.y1));
866
+ const rel = (y - this.chatScrollbar.y0) / Math.max(1, viewH - 1);
867
+ this.setChatScroll(Math.round((1 - rel) * this.chatScrollbar.scrollMax));
868
+ this.selection = undefined;
869
+ return;
870
+ }
871
+ if (mouse.kind === "button" && !mouse.release) {
872
+ const region = this.hitSelectable(mouse.y);
873
+ if (!region) {
874
+ this.selection = undefined;
875
+ return;
876
+ }
877
+ this.selection = { anchorY: mouse.y, focusY: mouse.y };
878
+ this.requestRender();
879
+ return;
880
+ }
881
+ if (!this.selection)
882
+ return;
883
+ if (mouse.kind === "move") {
884
+ this.selection.focusY = mouse.y;
885
+ this.requestRender();
886
+ return;
887
+ }
888
+ if (mouse.kind === "button" && mouse.release) {
889
+ this.selection.focusY = mouse.y;
890
+ const text = this.selectedText();
891
+ this.selection = undefined;
892
+ this.requestRender();
893
+ if (text)
894
+ this.onTextSelected?.(text);
895
+ }
896
+ }
897
+ hitSelectable(y) {
898
+ return this.chatHitRegions.find((region) => y >= region.y0 + 1 && y <= region.y1 + 1);
899
+ }
900
+ selectedText() {
901
+ if (!this.selection)
902
+ return undefined;
903
+ const lo = Math.min(this.selection.anchorY, this.selection.focusY);
904
+ const hi = Math.max(this.selection.anchorY, this.selection.focusY);
905
+ if (lo === hi)
906
+ return undefined;
907
+ const lines = [];
908
+ for (const region of this.chatHitRegions) {
909
+ for (let i = 0; i < region.lines.length; i++) {
910
+ const y = region.y0 + i + 1;
911
+ if (y < lo || y > hi)
912
+ continue;
913
+ lines.push(stripAnsiForCopy(region.lines[i] ?? ""));
914
+ }
915
+ }
916
+ const text = lines.join("\n").trim();
917
+ return text.length > 0 ? text : undefined;
742
918
  }
743
919
  handleInput(data) {
744
920
  if (this.consumeOsc11BackgroundResponse(data)) {
@@ -1609,4 +1785,28 @@ export class TUI extends Container {
1609
1785
  });
1610
1786
  }
1611
1787
  }
1788
+ function padToWidth(line, width) {
1789
+ const vis = visibleWidth(line);
1790
+ if (vis >= width)
1791
+ return line;
1792
+ return line + " ".repeat(width - vis);
1793
+ }
1794
+ function applySelectionHighlight(line, width) {
1795
+ const padded = padToWidth(line, width);
1796
+ return `\x1b[7m${padded}\x1b[27m`;
1797
+ }
1798
+ function stripAnsiForCopy(line) {
1799
+ let out = "";
1800
+ let i = 0;
1801
+ while (i < line.length) {
1802
+ const ansi = extractAnsiCode(line, i);
1803
+ if (ansi) {
1804
+ i += ansi.length;
1805
+ continue;
1806
+ }
1807
+ out += line[i];
1808
+ i++;
1809
+ }
1810
+ return out.replace(/\s+$/, "");
1811
+ }
1612
1812
  //# sourceMappingURL=tui.js.map