@gajae-code/tui 0.8.2 → 0.9.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.
@@ -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...
@@ -1779,49 +1910,48 @@ export class Editor implements Component, Focusable {
1779
1910
  #handlePaste(pastedText: string): void {
1780
1911
  this.#historyIndex = -1; // Exit history browsing mode
1781
1912
  this.#resetKillSequence();
1782
- this.#recordUndoState();
1913
+ const hadAutocomplete = this.#autocompleteState !== null;
1914
+ this.#cancelAutocomplete();
1915
+ if (hadAutocomplete) {
1916
+ this.onAutocompleteUpdate?.();
1917
+ }
1783
1918
 
1784
- this.#withUndoSuspended(() => {
1785
- // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1786
- // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1787
- // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1788
- // per-char filter below preserves newlines instead of stripping ESC and
1789
- // leaking the printable tail (e.g. "[106;5u") into the editor.
1790
- const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1791
- const cp = Number(code);
1792
- if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1793
- if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1794
- return match;
1795
- });
1919
+ // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1920
+ // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1921
+ // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1922
+ // per-char filter below preserves newlines instead of stripping ESC and
1923
+ // leaking the printable tail (e.g. "[106;5u") into the editor.
1924
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1925
+ const cp = Number(code);
1926
+ if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1927
+ if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1928
+ return match;
1929
+ });
1796
1930
 
1797
- // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
1798
- // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
1799
- // land in the buffer as the same precomposed syllables a terminal
1800
- // renders — without this, cursor column accounting drifts by
1801
- // `(NFD cells − NFC cells)` and the visible glyph desyncs from the
1802
- // hardware cursor. Matches the `Input` component's prior fix; this
1803
- // is the same fix on the real GJC prompt component (`Editor`).
1804
- const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
1805
-
1806
- // Convert tabs to spaces (4 spaces per tab)
1807
- const tabExpandedText = cleanText.replace(/\t/g, " ");
1808
-
1809
- // Filter out non-printable characters except newlines
1810
- let filteredText = tabExpandedText
1811
- .split("")
1812
- .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1813
- .join("");
1814
-
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
- }
1931
+ // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
1932
+ // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
1933
+ // land in the buffer as the same precomposed syllables a terminal
1934
+ // renders — without this, cursor column accounting drifts by
1935
+ // `(NFD cells − NFC cells)` and the visible glyph desyncs from the
1936
+ // hardware cursor. Matches the `Input` component's prior fix; this
1937
+ // is the same fix on the real GJC prompt component (`Editor`).
1938
+ const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
1939
+
1940
+ // Convert tabs to spaces (4 spaces per tab)
1941
+ const tabExpandedText = cleanText.replace(/\t/g, " ");
1942
+
1943
+ // Filter out non-printable characters except newlines
1944
+ const filteredText = tabExpandedText
1945
+ .split("")
1946
+ .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1947
+ .join("");
1824
1948
 
1949
+ // Nothing survived filtering: the buffer is untouched, so don't record
1950
+ // an undo snapshot — a no-op entry would make the next undo appear dead.
1951
+ if (filteredText.length === 0) return;
1952
+
1953
+ this.#recordUndoState();
1954
+ this.#withUndoSuspended(() => {
1825
1955
  // Split into lines
1826
1956
  const pastedLines = filteredText.split("\n");
1827
1957
 
@@ -1843,15 +1973,10 @@ export class Editor implements Component, Focusable {
1843
1973
  return;
1844
1974
  }
1845
1975
 
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
1976
+ // Paste is literal input, not typed input. Insert atomically so leading
1977
+ // trigger characters such as "/", "#", "@", ":", or path-like text do
1978
+ // not open or update autocomplete lists while preserving normal typed
1979
+ // trigger behavior.
1855
1980
  this.#insertTextAtCursor(filteredText);
1856
1981
  });
1857
1982
  }
@@ -1896,6 +2021,7 @@ export class Editor implements Component, Focusable {
1896
2021
  const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
1897
2022
 
1898
2023
  this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
2024
+ this.#bumpDocumentVersion();
1899
2025
  this.#pastes.clear();
1900
2026
  this.#pasteCounter = 0;
1901
2027
  this.#historyIndex = -1;
@@ -2087,6 +2213,7 @@ export class Editor implements Component, Focusable {
2087
2213
  #recordUndoState(): void {
2088
2214
  if (this.#suspendUndo) return;
2089
2215
  this.#undoStack.push(structuredClone(this.#state));
2216
+ this.#bumpDocumentVersion();
2090
2217
  }
2091
2218
 
2092
2219
  #applyUndo(): void {
@@ -2097,6 +2224,7 @@ export class Editor implements Component, Focusable {
2097
2224
  this.#resetKillSequence();
2098
2225
  this.#preferredVisualCol = null;
2099
2226
  Object.assign(this.#state, snapshot);
2227
+ this.#bumpDocumentVersion();
2100
2228
 
2101
2229
  if (this.onChange) {
2102
2230
  this.onChange(this.getText());
@@ -2800,6 +2928,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2800
2928
  );
2801
2929
 
2802
2930
  this.#state.lines = result.lines;
2931
+ this.#bumpDocumentVersion();
2803
2932
  this.#state.cursorLine = result.cursorLine;
2804
2933
  this.#setCursorCol(result.cursorCol);
2805
2934