@gajae-code/tui 0.8.2 → 0.9.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.
@@ -1,4 +1,4 @@
1
- import { getProjectDir, logger } from "@gajae-code/utils";
1
+ import { getProjectDir, logger, onDefaultTabWidthChange } from "@gajae-code/utils";
2
2
  import {
3
3
  type AutocompleteProvider,
4
4
  type CombinedAutocompleteProvider,
@@ -344,10 +344,27 @@ interface EditorState {
344
344
 
345
345
  interface LayoutLine {
346
346
  text: string;
347
+ visibleWidth: number;
348
+ logicalLine: number;
347
349
  hasCursor: boolean;
348
350
  cursorPos?: number;
349
351
  }
350
352
 
353
+ interface LayoutCacheKey {
354
+ docVersion: number;
355
+ contentWidth: number;
356
+ optionsKey: string;
357
+ }
358
+
359
+ interface LayoutCache {
360
+ key: LayoutCacheKey;
361
+ cursorLine: number;
362
+ cursorCol: number;
363
+ lines: LayoutLine[];
364
+ lineStarts: number[];
365
+ lineCounts: number[];
366
+ }
367
+
351
368
  export interface EditorTheme {
352
369
  borderColor: (str: string) => string;
353
370
  selectList: SelectListTheme;
@@ -377,6 +394,18 @@ interface HistoryStorage {
377
394
 
378
395
  type HistoryCursorAnchor = "start" | "end";
379
396
 
397
+ /** Test-only performance counters for advisory baseline tests. */
398
+ export const __editorPerfCounters = {
399
+ layoutTextInvocations: 0,
400
+ layoutLogicalLinesProcessed: 0,
401
+ visibleWidthMeasurements: 0,
402
+ reset(): void {
403
+ this.layoutTextInvocations = 0;
404
+ this.layoutLogicalLinesProcessed = 0;
405
+ this.visibleWidthMeasurements = 0;
406
+ },
407
+ };
408
+
380
409
  export class Editor implements Component, Focusable {
381
410
  #state: EditorState = {
382
411
  lines: [""],
@@ -404,9 +433,12 @@ export class Editor implements Component, Focusable {
404
433
  // Store last layout width for cursor navigation
405
434
  #lastLayoutWidth: number = 80;
406
435
  #paddingXOverride: number | undefined;
436
+ #rightGutterWidth = 0;
407
437
  #maxHeight?: number;
408
438
  #scrollOffset: number = 0;
409
439
  #wrappedLineCache: CachedWrappedLine[] = [];
440
+ #docVersion = 0;
441
+ #layoutCache: LayoutCache | undefined;
410
442
 
411
443
  // Emacs-style kill ring
412
444
  #killRing = new KillRing();
@@ -467,9 +499,25 @@ export class Editor implements Component, Focusable {
467
499
  #borderStyle: EditorBorderStyle = "round";
468
500
  #closedBorderBox = false;
469
501
 
502
+ #disposeTabWidthListener?: () => void;
503
+
470
504
  constructor(theme: EditorTheme) {
471
505
  this.#theme = theme;
472
506
  this.borderColor = theme.borderColor;
507
+ // Raw tabs can reach editor state via insertText()/autocomplete results
508
+ // (setText expands tabs by contract). visibleWidth + wrapping depend on the
509
+ // default tab width, so a runtime tab-width change must drop both caches.
510
+ this.#disposeTabWidthListener = onDefaultTabWidthChange(() => {
511
+ this.invalidate();
512
+ if (this.#inputPrefix !== undefined) {
513
+ this.#inputPrefixWidth = visibleWidth(this.#inputPrefix);
514
+ }
515
+ });
516
+ }
517
+
518
+ dispose(): void {
519
+ this.#disposeTabWidthListener?.();
520
+ this.#disposeTabWidthListener = undefined;
473
521
  }
474
522
 
475
523
  setAutocompleteProvider(provider: AutocompleteProvider): void {
@@ -491,6 +539,7 @@ export class Editor implements Component, Focusable {
491
539
  */
492
540
  setTopBorder(content: EditorTopBorder | undefined): void {
493
541
  this.#topBorderContent = content;
542
+ this.#invalidateLayoutCache();
494
543
  }
495
544
 
496
545
  /**
@@ -498,38 +547,46 @@ export class Editor implements Component, Focusable {
498
547
  */
499
548
  setBorderVisible(borderVisible: boolean): void {
500
549
  this.#borderVisible = borderVisible;
550
+ this.#invalidateLayoutCache();
501
551
  }
502
552
 
503
553
  setBorderStyle(borderStyle: EditorBorderStyle): void {
504
554
  this.#borderStyle = borderStyle;
555
+ this.#invalidateLayoutCache();
505
556
  }
506
557
 
507
558
  setClosedBorderBox(closedBorderBox: boolean): void {
508
559
  this.#closedBorderBox = closedBorderBox;
560
+ this.#invalidateLayoutCache();
509
561
  }
510
562
 
511
563
  setPromptGutter(promptGutter: string | undefined): void {
512
564
  this.#promptGutter = promptGutter;
565
+ this.#invalidateLayoutCache();
513
566
  }
514
567
 
515
568
  setInputPrefix(inputPrefix: string | undefined): void {
516
569
  this.#inputPrefix = inputPrefix;
517
570
  this.#inputPrefixWidth = inputPrefix ? visibleWidth(inputPrefix) : 0;
571
+ this.#invalidateLayoutCache();
518
572
  }
519
573
 
520
574
  setPlaceholder(placeholder: string | undefined): void {
521
575
  const trimmed = placeholder?.trim();
522
576
  this.#placeholder = trimmed ? trimmed : undefined;
577
+ this.#invalidateLayoutCache();
523
578
  }
524
579
 
525
580
  /**
526
581
  * Get the available width for top border content given a total terminal width.
527
- * Accounts for the border characters and horizontal padding when visible.
582
+ * Accounts for right gutter, border characters, and horizontal padding when visible.
528
583
  */
529
584
  getTopBorderAvailableWidth(terminalWidth: number): number {
585
+ const rightGutterWidth = Math.min(this.#rightGutterWidth, Math.max(0, terminalWidth - 1));
586
+ const renderWidth = Math.max(1, terminalWidth - rightGutterWidth);
530
587
  const paddingX = this.#getEditorPaddingX();
531
588
  const borderWidth = this.#getHorizontalChromeWidth(paddingX);
532
- return Math.max(0, terminalWidth - borderWidth * 2);
589
+ return Math.max(0, renderWidth - borderWidth * 2);
533
590
  }
534
591
 
535
592
  /**
@@ -537,6 +594,7 @@ export class Editor implements Component, Focusable {
537
594
  */
538
595
  setUseTerminalCursor(useTerminalCursor: boolean): void {
539
596
  this.#useTerminalCursor = useTerminalCursor;
597
+ this.#invalidateLayoutCache();
540
598
  }
541
599
 
542
600
  getUseTerminalCursor(): boolean {
@@ -551,6 +609,11 @@ export class Editor implements Component, Focusable {
551
609
 
552
610
  setPaddingX(paddingX: number): void {
553
611
  this.#paddingXOverride = Math.max(0, paddingX);
612
+ this.#invalidateLayoutCache();
613
+ }
614
+
615
+ setRightGutterWidth(width: number): void {
616
+ this.#rightGutterWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0;
554
617
  }
555
618
 
556
619
  getAutocompleteMaxVisible(): number {
@@ -640,10 +703,21 @@ export class Editor implements Component, Focusable {
640
703
  this.onChange(this.getText());
641
704
  }
642
705
  this.#wrappedLineCache.length = 0;
706
+ this.#bumpDocumentVersion();
643
707
  }
644
708
 
645
709
  invalidate(): void {
646
710
  this.#wrappedLineCache.length = 0;
711
+ this.#layoutCache = undefined;
712
+ }
713
+
714
+ #bumpDocumentVersion(): void {
715
+ this.#docVersion += 1;
716
+ this.#layoutCache = undefined;
717
+ }
718
+
719
+ #invalidateLayoutCache(): void {
720
+ this.#layoutCache = undefined;
647
721
  }
648
722
 
649
723
  #getEditorPaddingX(): number {
@@ -789,12 +863,14 @@ export class Editor implements Component, Focusable {
789
863
  }
790
864
 
791
865
  render(width: number): string[] {
866
+ const rightGutterWidth = Math.min(this.#rightGutterWidth, Math.max(0, width - 1));
867
+ const renderWidth = Math.max(1, width - rightGutterWidth);
792
868
  const paddingX = this.#getEditorPaddingX();
793
869
  const borderVisible = this.#borderVisible;
794
- const promptGutter = this.#getPromptGutter(width, paddingX);
795
- const contentAreaWidth = this.#getContentWidth(width, paddingX);
870
+ const promptGutter = this.#getPromptGutter(renderWidth, paddingX);
871
+ const contentAreaWidth = this.#getContentWidth(renderWidth, paddingX);
796
872
  const inputPrefixWidth = this.#inputPrefixWidth;
797
- const layoutWidth = Math.max(1, this.#getLayoutWidth(width, paddingX) - inputPrefixWidth);
873
+ const layoutWidth = Math.max(1, this.#getLayoutWidth(renderWidth, paddingX) - inputPrefixWidth);
798
874
  this.#lastLayoutWidth = layoutWidth;
799
875
 
800
876
  // Box-drawing characters for the configured input box shape.
@@ -815,7 +891,7 @@ export class Editor implements Component, Focusable {
815
891
 
816
892
  if (borderVisible) {
817
893
  // Render top border: ╭─ [status content] ────────────────╮
818
- const topFillWidth = Math.max(0, width - borderWidth * 2);
894
+ const topFillWidth = Math.max(0, renderWidth - borderWidth * 2);
819
895
  if (this.#topBorderContent) {
820
896
  const { content, width: statusWidth } = this.#topBorderContent;
821
897
  if (statusWidth <= topFillWidth) {
@@ -847,7 +923,7 @@ export class Editor implements Component, Focusable {
847
923
  for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) {
848
924
  const layoutLine = visibleLayoutLines[visibleIndex]!;
849
925
  let displayText = layoutLine.text;
850
- let displayWidth = visibleWidth(layoutLine.text);
926
+ let displayWidth = layoutLine.visibleWidth;
851
927
  let cursorInPadding = false;
852
928
  const absoluteVisibleIndex = this.#scrollOffset + visibleIndex;
853
929
  const showPromptGutter = promptGutter !== undefined && visibleIndex === 0;
@@ -1020,7 +1096,7 @@ export class Editor implements Component, Focusable {
1020
1096
  }
1021
1097
 
1022
1098
  if (borderVisible && this.#closedBorderBox) {
1023
- const bottomFillWidth = Math.max(0, width - borderWidth * 2);
1099
+ const bottomFillWidth = Math.max(0, renderWidth - borderWidth * 2);
1024
1100
  const bottomLeftClosed = this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(paddingX)}`);
1025
1101
  const bottomRightClosed = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.bottomRight}`);
1026
1102
  result.push(bottomLeftClosed + horizontal.repeat(bottomFillWidth) + bottomRightClosed);
@@ -1028,10 +1104,15 @@ export class Editor implements Component, Focusable {
1028
1104
 
1029
1105
  // Add autocomplete list if active
1030
1106
  if (this.#autocompleteState && this.#autocompleteList) {
1031
- const autocompleteResult = this.#autocompleteList.render(width);
1107
+ const autocompleteResult = this.#autocompleteList.render(renderWidth);
1032
1108
  result.push(...autocompleteResult);
1033
1109
  }
1034
1110
 
1111
+ if (rightGutterWidth > 0) {
1112
+ const rightGutter = padding(rightGutterWidth);
1113
+ return result.map(line => line + rightGutter);
1114
+ }
1115
+
1035
1116
  return result;
1036
1117
  }
1037
1118
 
@@ -1132,6 +1213,7 @@ export class Editor implements Component, Focusable {
1132
1213
  );
1133
1214
 
1134
1215
  this.#state.lines = result.lines;
1216
+ this.#bumpDocumentVersion();
1135
1217
  this.#state.cursorLine = result.cursorLine;
1136
1218
  this.#setCursorCol(result.cursorCol);
1137
1219
 
@@ -1171,6 +1253,7 @@ export class Editor implements Component, Focusable {
1171
1253
  );
1172
1254
 
1173
1255
  this.#state.lines = result.lines;
1256
+ this.#bumpDocumentVersion();
1174
1257
  this.#state.cursorLine = result.cursorLine;
1175
1258
  this.#setCursorCol(result.cursorCol);
1176
1259
  result.onApplied?.();
@@ -1192,6 +1275,7 @@ export class Editor implements Component, Focusable {
1192
1275
  );
1193
1276
 
1194
1277
  this.#state.lines = result.lines;
1278
+ this.#bumpDocumentVersion();
1195
1279
  this.#state.cursorLine = result.cursorLine;
1196
1280
  this.#setCursorCol(result.cursorCol);
1197
1281
 
@@ -1312,6 +1396,7 @@ export class Editor implements Component, Focusable {
1312
1396
  syncResult.prefix,
1313
1397
  );
1314
1398
  this.#state.lines = result.lines;
1399
+ this.#bumpDocumentVersion();
1315
1400
  this.#state.cursorLine = result.cursorLine;
1316
1401
  this.#setCursorCol(result.cursorCol);
1317
1402
  result.onApplied?.();
@@ -1445,119 +1530,175 @@ export class Editor implements Component, Focusable {
1445
1530
  return this.#wrappedLineCache.length;
1446
1531
  }
1447
1532
 
1448
- #layoutText(contentWidth: number): LayoutLine[] {
1533
+ #makeLayoutCacheKey(contentWidth: number): LayoutCacheKey {
1534
+ return {
1535
+ docVersion: this.#docVersion,
1536
+ contentWidth,
1537
+ optionsKey: JSON.stringify({
1538
+ borderVisible: this.#borderVisible,
1539
+ borderStyle: this.#borderStyle,
1540
+ closedBorderBox: this.#closedBorderBox,
1541
+ inputPrefix: this.#inputPrefix,
1542
+ inputPrefixWidth: this.#inputPrefixWidth,
1543
+ placeholder: this.#placeholder,
1544
+ promptGutter: this.#promptGutter,
1545
+ useTerminalCursor: this.#useTerminalCursor,
1546
+ cursorOverride: this.cursorOverride,
1547
+ cursorOverrideWidth: this.cursorOverrideWidth,
1548
+ autocompleteState: this.#autocompleteState,
1549
+ autocompletePrefix: this.#autocompletePrefix,
1550
+ autocompleteHint: this.#autocompleteList?.getSelectedItem()?.hint,
1551
+ }),
1552
+ };
1553
+ }
1554
+
1555
+ #layoutLine(text: string, logicalLine: number, hasCursor: boolean, cursorPos?: number): LayoutLine {
1556
+ __editorPerfCounters.visibleWidthMeasurements += 1;
1557
+ return {
1558
+ text,
1559
+ visibleWidth: visibleWidth(text),
1560
+ logicalLine,
1561
+ hasCursor,
1562
+ cursorPos,
1563
+ };
1564
+ }
1565
+
1566
+ #layoutLogicalLine(lineIndex: number, contentWidth: number): LayoutLine[] {
1567
+ __editorPerfCounters.layoutLogicalLinesProcessed += 1;
1568
+ const line = this.#state.lines[lineIndex] || "";
1569
+ const isCurrentLine = lineIndex === this.#state.cursorLine;
1570
+ const wrappedLine = this.#getWrappedLine(lineIndex, contentWidth);
1449
1571
  const layoutLines: LayoutLine[] = [];
1450
1572
 
1451
- if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
1452
- // Empty editor — keep the wrap cache bounded by document size like
1453
- // the non-empty path below (stale entries from a previously large
1454
- // buffer must not be retained).
1455
- this.#wrappedLineCache.length = this.#state.lines.length;
1456
- layoutLines.push({
1457
- text: "",
1458
- hasCursor: true,
1459
- cursorPos: 0,
1460
- });
1573
+ if (wrappedLine.width <= contentWidth) {
1574
+ layoutLines.push(
1575
+ this.#layoutLine(line, lineIndex, isCurrentLine, isCurrentLine ? this.#state.cursorCol : undefined),
1576
+ );
1461
1577
  return layoutLines;
1462
1578
  }
1463
1579
 
1464
- // Process each logical line
1465
- for (let i = 0; i < this.#state.lines.length; i++) {
1466
- const line = this.#state.lines[i] || "";
1467
- const isCurrentLine = i === this.#state.cursorLine;
1468
- const wrappedLine = this.#getWrappedLine(i, contentWidth);
1469
-
1470
- if (wrappedLine.width <= contentWidth) {
1471
- // Line fits in one layout line
1472
- if (isCurrentLine) {
1473
- layoutLines.push({
1474
- text: line,
1475
- hasCursor: true,
1476
- cursorPos: this.#state.cursorCol,
1477
- });
1478
- } else {
1479
- layoutLines.push({
1480
- text: line,
1481
- hasCursor: false,
1482
- });
1483
- }
1484
- } else {
1485
- // Line needs wrapping - use word-aware wrapping
1486
- const chunks = wrappedLine.chunks;
1487
-
1488
- for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1489
- const chunk = chunks[chunkIndex];
1490
- if (!chunk) continue;
1491
-
1492
- const cursorPos = this.#state.cursorCol;
1493
- const isLastChunk = chunkIndex === chunks.length - 1;
1580
+ const chunks = wrappedLine.chunks;
1581
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1582
+ const chunk = chunks[chunkIndex];
1583
+ if (!chunk) continue;
1494
1584
 
1495
- // Determine if cursor is in this chunk
1496
- // For word-wrapped chunks, we need to handle the case where
1497
- // cursor might be in trimmed whitespace at end of chunk
1498
- let hasCursorInChunk = false;
1499
- let adjustedCursorPos = 0;
1585
+ const cursorPos = this.#state.cursorCol;
1586
+ const isLastChunk = chunkIndex === chunks.length - 1;
1587
+ let hasCursorInChunk = false;
1588
+ let adjustedCursorPos = 0;
1500
1589
 
1501
- if (isCurrentLine) {
1502
- if (isLastChunk) {
1503
- // Last chunk: cursor belongs here if >= startIndex
1504
- hasCursorInChunk = cursorPos >= chunk.startIndex;
1505
- adjustedCursorPos = cursorPos - chunk.startIndex;
1506
- } else {
1507
- // Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
1508
- // But we need to handle the visual position in the trimmed text
1509
- hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
1510
- if (hasCursorInChunk) {
1511
- adjustedCursorPos = cursorPos - chunk.startIndex;
1512
- // Clamp to text length (in case cursor was in trimmed whitespace)
1513
- if (adjustedCursorPos > chunk.text.length) {
1514
- adjustedCursorPos = chunk.text.length;
1515
- }
1516
- }
1590
+ if (isCurrentLine) {
1591
+ if (isLastChunk) {
1592
+ hasCursorInChunk = cursorPos >= chunk.startIndex;
1593
+ adjustedCursorPos = cursorPos - chunk.startIndex;
1594
+ } else {
1595
+ hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
1596
+ if (hasCursorInChunk) {
1597
+ adjustedCursorPos = cursorPos - chunk.startIndex;
1598
+ if (adjustedCursorPos > chunk.text.length) {
1599
+ adjustedCursorPos = chunk.text.length;
1517
1600
  }
1518
1601
  }
1602
+ }
1603
+ }
1519
1604
 
1520
- if (hasCursorInChunk) {
1521
- let displayChunkText = chunk.text;
1522
- let displayCursorPos = adjustedCursorPos;
1523
- if (displayCursorPos > displayChunkText.length) {
1524
- let hiddenWhitespaceWidth = displayCursorPos - displayChunkText.length;
1525
- const displayChunkWidth = visibleWidth(displayChunkText);
1526
- if (displayChunkWidth + hiddenWhitespaceWidth <= contentWidth) {
1527
- displayChunkText += padding(hiddenWhitespaceWidth);
1528
- } else {
1529
- layoutLines.push({
1530
- text: displayChunkText,
1531
- hasCursor: false,
1532
- });
1533
- hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
1534
- while (hiddenWhitespaceWidth > contentWidth) {
1535
- layoutLines.push({
1536
- text: padding(contentWidth),
1537
- hasCursor: false,
1538
- });
1539
- hiddenWhitespaceWidth -= contentWidth;
1540
- }
1541
- displayChunkText = padding(hiddenWhitespaceWidth);
1542
- displayCursorPos = hiddenWhitespaceWidth;
1543
- }
1544
- }
1545
- layoutLines.push({
1546
- text: displayChunkText,
1547
- hasCursor: true,
1548
- cursorPos: displayCursorPos,
1549
- });
1605
+ if (hasCursorInChunk) {
1606
+ let displayChunkText = chunk.text;
1607
+ let displayCursorPos = adjustedCursorPos;
1608
+ if (displayCursorPos > displayChunkText.length) {
1609
+ let hiddenWhitespaceWidth = displayCursorPos - displayChunkText.length;
1610
+ const displayChunkWidth = visibleWidth(displayChunkText);
1611
+ __editorPerfCounters.visibleWidthMeasurements += 1;
1612
+ if (displayChunkWidth + hiddenWhitespaceWidth <= contentWidth) {
1613
+ displayChunkText += padding(hiddenWhitespaceWidth);
1550
1614
  } else {
1551
- layoutLines.push({
1552
- text: chunk.text,
1553
- hasCursor: false,
1554
- });
1615
+ layoutLines.push(this.#layoutLine(displayChunkText, lineIndex, false));
1616
+ hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
1617
+ while (hiddenWhitespaceWidth > contentWidth) {
1618
+ layoutLines.push(this.#layoutLine(padding(contentWidth), lineIndex, false));
1619
+ hiddenWhitespaceWidth -= contentWidth;
1620
+ }
1621
+ displayChunkText = padding(hiddenWhitespaceWidth);
1622
+ displayCursorPos = hiddenWhitespaceWidth;
1555
1623
  }
1556
1624
  }
1625
+ layoutLines.push(this.#layoutLine(displayChunkText, lineIndex, true, displayCursorPos));
1626
+ } else {
1627
+ layoutLines.push(this.#layoutLine(chunk.text, lineIndex, false));
1628
+ }
1629
+ }
1630
+
1631
+ return layoutLines;
1632
+ }
1633
+
1634
+ #sameLayoutCacheKey(a: LayoutCacheKey, b: LayoutCacheKey): boolean {
1635
+ return a.docVersion === b.docVersion && a.contentWidth === b.contentWidth && a.optionsKey === b.optionsKey;
1636
+ }
1637
+
1638
+ #replaceCachedLogicalLine(cache: LayoutCache, lineIndex: number, contentWidth: number): void {
1639
+ const start = cache.lineStarts[lineIndex] ?? cache.lines.length;
1640
+ const oldCount = cache.lineCounts[lineIndex] ?? 0;
1641
+ const replacement = this.#layoutLogicalLine(lineIndex, contentWidth);
1642
+ cache.lines.splice(start, oldCount, ...replacement);
1643
+ cache.lineCounts[lineIndex] = replacement.length;
1644
+ const delta = replacement.length - oldCount;
1645
+ if (delta !== 0) {
1646
+ for (let i = lineIndex + 1; i < cache.lineStarts.length; i++) {
1647
+ cache.lineStarts[i] = (cache.lineStarts[i] ?? 0) + delta;
1648
+ }
1649
+ }
1650
+ }
1651
+
1652
+ #patchCursorInCachedLayout(cache: LayoutCache, contentWidth: number): LayoutLine[] {
1653
+ const previousLine = cache.cursorLine;
1654
+ const currentLine = this.#state.cursorLine;
1655
+ this.#replaceCachedLogicalLine(cache, previousLine, contentWidth);
1656
+ if (currentLine !== previousLine) {
1657
+ this.#replaceCachedLogicalLine(cache, currentLine, contentWidth);
1658
+ }
1659
+ cache.cursorLine = currentLine;
1660
+ cache.cursorCol = this.#state.cursorCol;
1661
+ return cache.lines;
1662
+ }
1663
+
1664
+ #layoutText(contentWidth: number): LayoutLine[] {
1665
+ __editorPerfCounters.layoutTextInvocations += 1;
1666
+ const key = this.#makeLayoutCacheKey(contentWidth);
1667
+ const cached = this.#layoutCache;
1668
+ if (cached && this.#sameLayoutCacheKey(cached.key, key)) {
1669
+ if (cached.cursorLine === this.#state.cursorLine && cached.cursorCol === this.#state.cursorCol) {
1670
+ return cached.lines;
1671
+ }
1672
+ return this.#patchCursorInCachedLayout(cached, contentWidth);
1673
+ }
1674
+
1675
+ const layoutLines: LayoutLine[] = [];
1676
+ const lineStarts: number[] = [];
1677
+ const lineCounts: number[] = [];
1678
+
1679
+ if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
1680
+ this.#wrappedLineCache.length = this.#state.lines.length;
1681
+ lineStarts[0] = 0;
1682
+ lineCounts[0] = 1;
1683
+ layoutLines.push(this.#layoutLine("", 0, true, 0));
1684
+ } else {
1685
+ for (let i = 0; i < this.#state.lines.length; i++) {
1686
+ lineStarts[i] = layoutLines.length;
1687
+ const logicalLayout = this.#layoutLogicalLine(i, contentWidth);
1688
+ lineCounts[i] = logicalLayout.length;
1689
+ layoutLines.push(...logicalLayout);
1557
1690
  }
1558
1691
  }
1559
1692
 
1560
1693
  this.#wrappedLineCache.length = this.#state.lines.length;
1694
+ this.#layoutCache = {
1695
+ key,
1696
+ cursorLine: this.#state.cursorLine,
1697
+ cursorCol: this.#state.cursorCol,
1698
+ lines: layoutLines,
1699
+ lineStarts,
1700
+ lineCounts,
1701
+ };
1561
1702
  return layoutLines;
1562
1703
  }
1563
1704
 
@@ -1629,6 +1770,7 @@ export class Editor implements Component, Focusable {
1629
1770
  this.#resetKillSequence();
1630
1771
  this.#preferredVisualCol = null;
1631
1772
  this.#state.lines[this.#state.cursorLine] = beforeTransient + afterTransient;
1773
+ this.#bumpDocumentVersion();
1632
1774
  this.#setCursorCol(transientStartCol);
1633
1775
 
1634
1776
  while (true) {
@@ -1677,18 +1819,7 @@ export class Editor implements Component, Focusable {
1677
1819
  /** Insert text at the current cursor position */
1678
1820
  insertText(text: string): void {
1679
1821
  this.#exitHistoryForEditing();
1680
- this.#resetKillSequence();
1681
- this.#recordUndoState();
1682
-
1683
- const line = this.#state.lines[this.#state.cursorLine] || "";
1684
- const inserted = insertTextNfcAt(line, this.#state.cursorCol, text);
1685
-
1686
- this.#state.lines[this.#state.cursorLine] = inserted.line;
1687
- this.#setCursorCol(inserted.cursorCol);
1688
-
1689
- if (this.onChange) {
1690
- this.onChange(this.getText());
1691
- }
1822
+ this.#insertTextAtCursor(text);
1692
1823
  }
1693
1824
 
1694
1825
  // All the editor methods from before...
@@ -1780,6 +1911,11 @@ export class Editor implements Component, Focusable {
1780
1911
  this.#historyIndex = -1; // Exit history browsing mode
1781
1912
  this.#resetKillSequence();
1782
1913
  this.#recordUndoState();
1914
+ const hadAutocomplete = this.#autocompleteState !== null;
1915
+ this.#cancelAutocomplete();
1916
+ if (hadAutocomplete) {
1917
+ this.onAutocompleteUpdate?.();
1918
+ }
1783
1919
 
1784
1920
  this.#withUndoSuspended(() => {
1785
1921
  // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
@@ -1807,21 +1943,12 @@ export class Editor implements Component, Focusable {
1807
1943
  const tabExpandedText = cleanText.replace(/\t/g, " ");
1808
1944
 
1809
1945
  // Filter out non-printable characters except newlines
1810
- let filteredText = tabExpandedText
1946
+ const filteredText = tabExpandedText
1811
1947
  .split("")
1812
1948
  .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1813
1949
  .join("");
1814
1950
 
1815
- // If pasting a file path (starts with /, ~, or .) and the character before
1816
- // the cursor is a word character, prepend a space for better readability
1817
- if (/^[/~.]/.test(filteredText)) {
1818
- const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1819
- const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
1820
- if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
1821
- filteredText = ` ${filteredText}`;
1822
- }
1823
- }
1824
-
1951
+ if (filteredText.length === 0) return;
1825
1952
  // Split into lines
1826
1953
  const pastedLines = filteredText.split("\n");
1827
1954
 
@@ -1843,15 +1970,10 @@ export class Editor implements Component, Focusable {
1843
1970
  return;
1844
1971
  }
1845
1972
 
1846
- if (pastedLines.length === 1) {
1847
- // Single line - insert character by character to trigger autocomplete
1848
- for (const char of filteredText) {
1849
- this.#insertCharacter(char);
1850
- }
1851
- return;
1852
- }
1853
-
1854
- // Multi-line paste - use insertTextAtCursor for proper handling
1973
+ // Paste is literal input, not typed input. Insert atomically so leading
1974
+ // trigger characters such as "/", "#", "@", ":", or path-like text do
1975
+ // not open or update autocomplete lists while preserving normal typed
1976
+ // trigger behavior.
1855
1977
  this.#insertTextAtCursor(filteredText);
1856
1978
  });
1857
1979
  }
@@ -1896,6 +2018,7 @@ export class Editor implements Component, Focusable {
1896
2018
  const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
1897
2019
 
1898
2020
  this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
2021
+ this.#bumpDocumentVersion();
1899
2022
  this.#pastes.clear();
1900
2023
  this.#pasteCounter = 0;
1901
2024
  this.#historyIndex = -1;
@@ -2087,6 +2210,7 @@ export class Editor implements Component, Focusable {
2087
2210
  #recordUndoState(): void {
2088
2211
  if (this.#suspendUndo) return;
2089
2212
  this.#undoStack.push(structuredClone(this.#state));
2213
+ this.#bumpDocumentVersion();
2090
2214
  }
2091
2215
 
2092
2216
  #applyUndo(): void {
@@ -2097,6 +2221,7 @@ export class Editor implements Component, Focusable {
2097
2221
  this.#resetKillSequence();
2098
2222
  this.#preferredVisualCol = null;
2099
2223
  Object.assign(this.#state, snapshot);
2224
+ this.#bumpDocumentVersion();
2100
2225
 
2101
2226
  if (this.onChange) {
2102
2227
  this.onChange(this.getText());
@@ -2800,6 +2925,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2800
2925
  );
2801
2926
 
2802
2927
  this.#state.lines = result.lines;
2928
+ this.#bumpDocumentVersion();
2803
2929
  this.#state.cursorLine = result.cursorLine;
2804
2930
  this.#setCursorCol(result.cursorCol);
2805
2931