@gajae-code/tui 0.8.1 → 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,5 +1,9 @@
1
- import { getProjectDir, logger } from "@gajae-code/utils";
2
- import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
1
+ import { getProjectDir, logger, onDefaultTabWidthChange } from "@gajae-code/utils";
2
+ import {
3
+ type AutocompleteProvider,
4
+ type CombinedAutocompleteProvider,
5
+ extractSlashCommandTokenPrefix,
6
+ } from "../autocomplete";
3
7
  import { BracketedPasteHandler } from "../bracketed-paste";
4
8
  import { getKeybindings, type KeybindingsManager } from "../keybindings";
5
9
  import { extractPrintableText, matchesKey } from "../keys";
@@ -340,10 +344,27 @@ interface EditorState {
340
344
 
341
345
  interface LayoutLine {
342
346
  text: string;
347
+ visibleWidth: number;
348
+ logicalLine: number;
343
349
  hasCursor: boolean;
344
350
  cursorPos?: number;
345
351
  }
346
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
+
347
368
  export interface EditorTheme {
348
369
  borderColor: (str: string) => string;
349
370
  selectList: SelectListTheme;
@@ -373,6 +394,18 @@ interface HistoryStorage {
373
394
 
374
395
  type HistoryCursorAnchor = "start" | "end";
375
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
+
376
409
  export class Editor implements Component, Focusable {
377
410
  #state: EditorState = {
378
411
  lines: [""],
@@ -400,9 +433,12 @@ export class Editor implements Component, Focusable {
400
433
  // Store last layout width for cursor navigation
401
434
  #lastLayoutWidth: number = 80;
402
435
  #paddingXOverride: number | undefined;
436
+ #rightGutterWidth = 0;
403
437
  #maxHeight?: number;
404
438
  #scrollOffset: number = 0;
405
439
  #wrappedLineCache: CachedWrappedLine[] = [];
440
+ #docVersion = 0;
441
+ #layoutCache: LayoutCache | undefined;
406
442
 
407
443
  // Emacs-style kill ring
408
444
  #killRing = new KillRing();
@@ -463,9 +499,25 @@ export class Editor implements Component, Focusable {
463
499
  #borderStyle: EditorBorderStyle = "round";
464
500
  #closedBorderBox = false;
465
501
 
502
+ #disposeTabWidthListener?: () => void;
503
+
466
504
  constructor(theme: EditorTheme) {
467
505
  this.#theme = theme;
468
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;
469
521
  }
470
522
 
471
523
  setAutocompleteProvider(provider: AutocompleteProvider): void {
@@ -487,6 +539,7 @@ export class Editor implements Component, Focusable {
487
539
  */
488
540
  setTopBorder(content: EditorTopBorder | undefined): void {
489
541
  this.#topBorderContent = content;
542
+ this.#invalidateLayoutCache();
490
543
  }
491
544
 
492
545
  /**
@@ -494,38 +547,46 @@ export class Editor implements Component, Focusable {
494
547
  */
495
548
  setBorderVisible(borderVisible: boolean): void {
496
549
  this.#borderVisible = borderVisible;
550
+ this.#invalidateLayoutCache();
497
551
  }
498
552
 
499
553
  setBorderStyle(borderStyle: EditorBorderStyle): void {
500
554
  this.#borderStyle = borderStyle;
555
+ this.#invalidateLayoutCache();
501
556
  }
502
557
 
503
558
  setClosedBorderBox(closedBorderBox: boolean): void {
504
559
  this.#closedBorderBox = closedBorderBox;
560
+ this.#invalidateLayoutCache();
505
561
  }
506
562
 
507
563
  setPromptGutter(promptGutter: string | undefined): void {
508
564
  this.#promptGutter = promptGutter;
565
+ this.#invalidateLayoutCache();
509
566
  }
510
567
 
511
568
  setInputPrefix(inputPrefix: string | undefined): void {
512
569
  this.#inputPrefix = inputPrefix;
513
570
  this.#inputPrefixWidth = inputPrefix ? visibleWidth(inputPrefix) : 0;
571
+ this.#invalidateLayoutCache();
514
572
  }
515
573
 
516
574
  setPlaceholder(placeholder: string | undefined): void {
517
575
  const trimmed = placeholder?.trim();
518
576
  this.#placeholder = trimmed ? trimmed : undefined;
577
+ this.#invalidateLayoutCache();
519
578
  }
520
579
 
521
580
  /**
522
581
  * Get the available width for top border content given a total terminal width.
523
- * Accounts for the border characters and horizontal padding when visible.
582
+ * Accounts for right gutter, border characters, and horizontal padding when visible.
524
583
  */
525
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);
526
587
  const paddingX = this.#getEditorPaddingX();
527
588
  const borderWidth = this.#getHorizontalChromeWidth(paddingX);
528
- return Math.max(0, terminalWidth - borderWidth * 2);
589
+ return Math.max(0, renderWidth - borderWidth * 2);
529
590
  }
530
591
 
531
592
  /**
@@ -533,6 +594,7 @@ export class Editor implements Component, Focusable {
533
594
  */
534
595
  setUseTerminalCursor(useTerminalCursor: boolean): void {
535
596
  this.#useTerminalCursor = useTerminalCursor;
597
+ this.#invalidateLayoutCache();
536
598
  }
537
599
 
538
600
  getUseTerminalCursor(): boolean {
@@ -547,6 +609,11 @@ export class Editor implements Component, Focusable {
547
609
 
548
610
  setPaddingX(paddingX: number): void {
549
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;
550
617
  }
551
618
 
552
619
  getAutocompleteMaxVisible(): number {
@@ -636,10 +703,21 @@ export class Editor implements Component, Focusable {
636
703
  this.onChange(this.getText());
637
704
  }
638
705
  this.#wrappedLineCache.length = 0;
706
+ this.#bumpDocumentVersion();
639
707
  }
640
708
 
641
709
  invalidate(): void {
642
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;
643
721
  }
644
722
 
645
723
  #getEditorPaddingX(): number {
@@ -785,12 +863,14 @@ export class Editor implements Component, Focusable {
785
863
  }
786
864
 
787
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);
788
868
  const paddingX = this.#getEditorPaddingX();
789
869
  const borderVisible = this.#borderVisible;
790
- const promptGutter = this.#getPromptGutter(width, paddingX);
791
- const contentAreaWidth = this.#getContentWidth(width, paddingX);
870
+ const promptGutter = this.#getPromptGutter(renderWidth, paddingX);
871
+ const contentAreaWidth = this.#getContentWidth(renderWidth, paddingX);
792
872
  const inputPrefixWidth = this.#inputPrefixWidth;
793
- const layoutWidth = Math.max(1, this.#getLayoutWidth(width, paddingX) - inputPrefixWidth);
873
+ const layoutWidth = Math.max(1, this.#getLayoutWidth(renderWidth, paddingX) - inputPrefixWidth);
794
874
  this.#lastLayoutWidth = layoutWidth;
795
875
 
796
876
  // Box-drawing characters for the configured input box shape.
@@ -811,7 +891,7 @@ export class Editor implements Component, Focusable {
811
891
 
812
892
  if (borderVisible) {
813
893
  // Render top border: ╭─ [status content] ────────────────╮
814
- const topFillWidth = Math.max(0, width - borderWidth * 2);
894
+ const topFillWidth = Math.max(0, renderWidth - borderWidth * 2);
815
895
  if (this.#topBorderContent) {
816
896
  const { content, width: statusWidth } = this.#topBorderContent;
817
897
  if (statusWidth <= topFillWidth) {
@@ -843,7 +923,7 @@ export class Editor implements Component, Focusable {
843
923
  for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) {
844
924
  const layoutLine = visibleLayoutLines[visibleIndex]!;
845
925
  let displayText = layoutLine.text;
846
- let displayWidth = visibleWidth(layoutLine.text);
926
+ let displayWidth = layoutLine.visibleWidth;
847
927
  let cursorInPadding = false;
848
928
  const absoluteVisibleIndex = this.#scrollOffset + visibleIndex;
849
929
  const showPromptGutter = promptGutter !== undefined && visibleIndex === 0;
@@ -1016,7 +1096,7 @@ export class Editor implements Component, Focusable {
1016
1096
  }
1017
1097
 
1018
1098
  if (borderVisible && this.#closedBorderBox) {
1019
- const bottomFillWidth = Math.max(0, width - borderWidth * 2);
1099
+ const bottomFillWidth = Math.max(0, renderWidth - borderWidth * 2);
1020
1100
  const bottomLeftClosed = this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(paddingX)}`);
1021
1101
  const bottomRightClosed = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.bottomRight}`);
1022
1102
  result.push(bottomLeftClosed + horizontal.repeat(bottomFillWidth) + bottomRightClosed);
@@ -1024,10 +1104,15 @@ export class Editor implements Component, Focusable {
1024
1104
 
1025
1105
  // Add autocomplete list if active
1026
1106
  if (this.#autocompleteState && this.#autocompleteList) {
1027
- const autocompleteResult = this.#autocompleteList.render(width);
1107
+ const autocompleteResult = this.#autocompleteList.render(renderWidth);
1028
1108
  result.push(...autocompleteResult);
1029
1109
  }
1030
1110
 
1111
+ if (rightGutterWidth > 0) {
1112
+ const rightGutter = padding(rightGutterWidth);
1113
+ return result.map(line => line + rightGutter);
1114
+ }
1115
+
1031
1116
  return result;
1032
1117
  }
1033
1118
 
@@ -1128,6 +1213,7 @@ export class Editor implements Component, Focusable {
1128
1213
  );
1129
1214
 
1130
1215
  this.#state.lines = result.lines;
1216
+ this.#bumpDocumentVersion();
1131
1217
  this.#state.cursorLine = result.cursorLine;
1132
1218
  this.#setCursorCol(result.cursorCol);
1133
1219
 
@@ -1167,6 +1253,7 @@ export class Editor implements Component, Focusable {
1167
1253
  );
1168
1254
 
1169
1255
  this.#state.lines = result.lines;
1256
+ this.#bumpDocumentVersion();
1170
1257
  this.#state.cursorLine = result.cursorLine;
1171
1258
  this.#setCursorCol(result.cursorCol);
1172
1259
  result.onApplied?.();
@@ -1188,6 +1275,7 @@ export class Editor implements Component, Focusable {
1188
1275
  );
1189
1276
 
1190
1277
  this.#state.lines = result.lines;
1278
+ this.#bumpDocumentVersion();
1191
1279
  this.#state.cursorLine = result.cursorLine;
1192
1280
  this.#setCursorCol(result.cursorCol);
1193
1281
 
@@ -1308,6 +1396,7 @@ export class Editor implements Component, Focusable {
1308
1396
  syncResult.prefix,
1309
1397
  );
1310
1398
  this.#state.lines = result.lines;
1399
+ this.#bumpDocumentVersion();
1311
1400
  this.#state.cursorLine = result.cursorLine;
1312
1401
  this.#setCursorCol(result.cursorCol);
1313
1402
  result.onApplied?.();
@@ -1441,119 +1530,175 @@ export class Editor implements Component, Focusable {
1441
1530
  return this.#wrappedLineCache.length;
1442
1531
  }
1443
1532
 
1444
- #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);
1445
1571
  const layoutLines: LayoutLine[] = [];
1446
1572
 
1447
- if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
1448
- // Empty editor — keep the wrap cache bounded by document size like
1449
- // the non-empty path below (stale entries from a previously large
1450
- // buffer must not be retained).
1451
- this.#wrappedLineCache.length = this.#state.lines.length;
1452
- layoutLines.push({
1453
- text: "",
1454
- hasCursor: true,
1455
- cursorPos: 0,
1456
- });
1573
+ if (wrappedLine.width <= contentWidth) {
1574
+ layoutLines.push(
1575
+ this.#layoutLine(line, lineIndex, isCurrentLine, isCurrentLine ? this.#state.cursorCol : undefined),
1576
+ );
1457
1577
  return layoutLines;
1458
1578
  }
1459
1579
 
1460
- // Process each logical line
1461
- for (let i = 0; i < this.#state.lines.length; i++) {
1462
- const line = this.#state.lines[i] || "";
1463
- const isCurrentLine = i === this.#state.cursorLine;
1464
- const wrappedLine = this.#getWrappedLine(i, contentWidth);
1465
-
1466
- if (wrappedLine.width <= contentWidth) {
1467
- // Line fits in one layout line
1468
- if (isCurrentLine) {
1469
- layoutLines.push({
1470
- text: line,
1471
- hasCursor: true,
1472
- cursorPos: this.#state.cursorCol,
1473
- });
1474
- } else {
1475
- layoutLines.push({
1476
- text: line,
1477
- hasCursor: false,
1478
- });
1479
- }
1480
- } else {
1481
- // Line needs wrapping - use word-aware wrapping
1482
- const chunks = wrappedLine.chunks;
1580
+ const chunks = wrappedLine.chunks;
1581
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1582
+ const chunk = chunks[chunkIndex];
1583
+ if (!chunk) continue;
1483
1584
 
1484
- for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1485
- const chunk = chunks[chunkIndex];
1486
- if (!chunk) continue;
1585
+ const cursorPos = this.#state.cursorCol;
1586
+ const isLastChunk = chunkIndex === chunks.length - 1;
1587
+ let hasCursorInChunk = false;
1588
+ let adjustedCursorPos = 0;
1487
1589
 
1488
- const cursorPos = this.#state.cursorCol;
1489
- const isLastChunk = chunkIndex === chunks.length - 1;
1490
-
1491
- // Determine if cursor is in this chunk
1492
- // For word-wrapped chunks, we need to handle the case where
1493
- // cursor might be in trimmed whitespace at end of chunk
1494
- let hasCursorInChunk = false;
1495
- let adjustedCursorPos = 0;
1496
-
1497
- if (isCurrentLine) {
1498
- if (isLastChunk) {
1499
- // Last chunk: cursor belongs here if >= startIndex
1500
- hasCursorInChunk = cursorPos >= chunk.startIndex;
1501
- adjustedCursorPos = cursorPos - chunk.startIndex;
1502
- } else {
1503
- // Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
1504
- // But we need to handle the visual position in the trimmed text
1505
- hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
1506
- if (hasCursorInChunk) {
1507
- adjustedCursorPos = cursorPos - chunk.startIndex;
1508
- // Clamp to text length (in case cursor was in trimmed whitespace)
1509
- if (adjustedCursorPos > chunk.text.length) {
1510
- adjustedCursorPos = chunk.text.length;
1511
- }
1512
- }
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;
1513
1600
  }
1514
1601
  }
1602
+ }
1603
+ }
1515
1604
 
1516
- if (hasCursorInChunk) {
1517
- let displayChunkText = chunk.text;
1518
- let displayCursorPos = adjustedCursorPos;
1519
- if (displayCursorPos > displayChunkText.length) {
1520
- let hiddenWhitespaceWidth = displayCursorPos - displayChunkText.length;
1521
- const displayChunkWidth = visibleWidth(displayChunkText);
1522
- if (displayChunkWidth + hiddenWhitespaceWidth <= contentWidth) {
1523
- displayChunkText += padding(hiddenWhitespaceWidth);
1524
- } else {
1525
- layoutLines.push({
1526
- text: displayChunkText,
1527
- hasCursor: false,
1528
- });
1529
- hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
1530
- while (hiddenWhitespaceWidth > contentWidth) {
1531
- layoutLines.push({
1532
- text: padding(contentWidth),
1533
- hasCursor: false,
1534
- });
1535
- hiddenWhitespaceWidth -= contentWidth;
1536
- }
1537
- displayChunkText = padding(hiddenWhitespaceWidth);
1538
- displayCursorPos = hiddenWhitespaceWidth;
1539
- }
1540
- }
1541
- layoutLines.push({
1542
- text: displayChunkText,
1543
- hasCursor: true,
1544
- cursorPos: displayCursorPos,
1545
- });
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);
1546
1614
  } else {
1547
- layoutLines.push({
1548
- text: chunk.text,
1549
- hasCursor: false,
1550
- });
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;
1551
1623
  }
1552
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);
1553
1690
  }
1554
1691
  }
1555
1692
 
1556
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
+ };
1557
1702
  return layoutLines;
1558
1703
  }
1559
1704
 
@@ -1625,6 +1770,7 @@ export class Editor implements Component, Focusable {
1625
1770
  this.#resetKillSequence();
1626
1771
  this.#preferredVisualCol = null;
1627
1772
  this.#state.lines[this.#state.cursorLine] = beforeTransient + afterTransient;
1773
+ this.#bumpDocumentVersion();
1628
1774
  this.#setCursorCol(transientStartCol);
1629
1775
 
1630
1776
  while (true) {
@@ -1673,18 +1819,7 @@ export class Editor implements Component, Focusable {
1673
1819
  /** Insert text at the current cursor position */
1674
1820
  insertText(text: string): void {
1675
1821
  this.#exitHistoryForEditing();
1676
- this.#resetKillSequence();
1677
- this.#recordUndoState();
1678
-
1679
- const line = this.#state.lines[this.#state.cursorLine] || "";
1680
- const inserted = insertTextNfcAt(line, this.#state.cursorCol, text);
1681
-
1682
- this.#state.lines[this.#state.cursorLine] = inserted.line;
1683
- this.#setCursorCol(inserted.cursorCol);
1684
-
1685
- if (this.onChange) {
1686
- this.onChange(this.getText());
1687
- }
1822
+ this.#insertTextAtCursor(text);
1688
1823
  }
1689
1824
 
1690
1825
  // All the editor methods from before...
@@ -1728,9 +1863,8 @@ export class Editor implements Component, Focusable {
1728
1863
 
1729
1864
  // Check if we should trigger or update autocomplete
1730
1865
  if (!this.#autocompleteState) {
1731
- // Auto-trigger for "/" at the start of a submitted command.
1732
- // Inline skill autocomplete starts after the token becomes "/skill...".
1733
- if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
1866
+ // Auto-trigger for slash command tokens.
1867
+ if (char === "/" && (this.#isAtStartOfSubmittedMessage() || this.#isInSlashTokenContext())) {
1734
1868
  this.#tryTriggerAutocomplete();
1735
1869
  }
1736
1870
  // Auto-trigger for "@" file reference (fuzzy search)
@@ -1777,6 +1911,11 @@ export class Editor implements Component, Focusable {
1777
1911
  this.#historyIndex = -1; // Exit history browsing mode
1778
1912
  this.#resetKillSequence();
1779
1913
  this.#recordUndoState();
1914
+ const hadAutocomplete = this.#autocompleteState !== null;
1915
+ this.#cancelAutocomplete();
1916
+ if (hadAutocomplete) {
1917
+ this.onAutocompleteUpdate?.();
1918
+ }
1780
1919
 
1781
1920
  this.#withUndoSuspended(() => {
1782
1921
  // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
@@ -1804,21 +1943,12 @@ export class Editor implements Component, Focusable {
1804
1943
  const tabExpandedText = cleanText.replace(/\t/g, " ");
1805
1944
 
1806
1945
  // Filter out non-printable characters except newlines
1807
- let filteredText = tabExpandedText
1946
+ const filteredText = tabExpandedText
1808
1947
  .split("")
1809
1948
  .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1810
1949
  .join("");
1811
1950
 
1812
- // If pasting a file path (starts with /, ~, or .) and the character before
1813
- // the cursor is a word character, prepend a space for better readability
1814
- if (/^[/~.]/.test(filteredText)) {
1815
- const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1816
- const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
1817
- if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
1818
- filteredText = ` ${filteredText}`;
1819
- }
1820
- }
1821
-
1951
+ if (filteredText.length === 0) return;
1822
1952
  // Split into lines
1823
1953
  const pastedLines = filteredText.split("\n");
1824
1954
 
@@ -1840,15 +1970,10 @@ export class Editor implements Component, Focusable {
1840
1970
  return;
1841
1971
  }
1842
1972
 
1843
- if (pastedLines.length === 1) {
1844
- // Single line - insert character by character to trigger autocomplete
1845
- for (const char of filteredText) {
1846
- this.#insertCharacter(char);
1847
- }
1848
- return;
1849
- }
1850
-
1851
- // 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.
1852
1977
  this.#insertTextAtCursor(filteredText);
1853
1978
  });
1854
1979
  }
@@ -1893,6 +2018,7 @@ export class Editor implements Component, Focusable {
1893
2018
  const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
1894
2019
 
1895
2020
  this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
2021
+ this.#bumpDocumentVersion();
1896
2022
  this.#pastes.clear();
1897
2023
  this.#pasteCounter = 0;
1898
2024
  this.#historyIndex = -1;
@@ -2084,6 +2210,7 @@ export class Editor implements Component, Focusable {
2084
2210
  #recordUndoState(): void {
2085
2211
  if (this.#suspendUndo) return;
2086
2212
  this.#undoStack.push(structuredClone(this.#state));
2213
+ this.#bumpDocumentVersion();
2087
2214
  }
2088
2215
 
2089
2216
  #applyUndo(): void {
@@ -2094,6 +2221,7 @@ export class Editor implements Component, Focusable {
2094
2221
  this.#resetKillSequence();
2095
2222
  this.#preferredVisualCol = null;
2096
2223
  Object.assign(this.#state, snapshot);
2224
+ this.#bumpDocumentVersion();
2097
2225
 
2098
2226
  if (this.onChange) {
2099
2227
  this.onChange(this.getText());
@@ -2599,6 +2727,12 @@ export class Editor implements Component, Focusable {
2599
2727
  : this.#state.cursorCol - 1
2600
2728
  : undefined;
2601
2729
 
2730
+ // Backward from column 0: lastIndexOf clamps a negative position to 0,
2731
+ // which would match the character under the cursor instead of skipping it
2732
+ if (!isForward && searchFrom !== undefined && searchFrom < 0) {
2733
+ continue;
2734
+ }
2735
+
2602
2736
  const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
2603
2737
 
2604
2738
  if (idx !== -1) {
@@ -2651,12 +2785,11 @@ export class Editor implements Component, Focusable {
2651
2785
  #getSlashTokenBeforeCursor(): string | null {
2652
2786
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2653
2787
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2654
- const match = beforeCursor.match(/(?:^|\s)(\/[^\s]*)$/);
2655
- return match?.[1] ?? null;
2788
+ return extractSlashCommandTokenPrefix(beforeCursor);
2656
2789
  }
2657
2790
 
2658
2791
  #isInSlashTokenContext(): boolean {
2659
- return this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true;
2792
+ return this.#getSlashTokenBeforeCursor() !== null;
2660
2793
  }
2661
2794
 
2662
2795
  #isSlashCommandNameAutocompleteSelection(): boolean {
@@ -2740,7 +2873,10 @@ export class Editor implements Component, Focusable {
2740
2873
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2741
2874
 
2742
2875
  // Check if we're in a slash command context
2743
- if (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) {
2876
+ if (
2877
+ (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) ||
2878
+ this.#isInSlashTokenContext()
2879
+ ) {
2744
2880
  this.#handleSlashCommandCompletion();
2745
2881
  } else {
2746
2882
  this.#forceFileAutocomplete(true);
@@ -2789,6 +2925,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2789
2925
  );
2790
2926
 
2791
2927
  this.#state.lines = result.lines;
2928
+ this.#bumpDocumentVersion();
2792
2929
  this.#state.cursorLine = result.cursorLine;
2793
2930
  this.#setCursorCol(result.cursorCol);
2794
2931