@sayknow-cli/tui 0.3.7 → 0.3.8

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 "@sayknow-cli/utils";
2
- import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
1
+ import { getProjectDir, logger, onDefaultTabWidthChange } from "@sayknow-cli/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;
@@ -1020,7 +1100,7 @@ export class Editor implements Component, Focusable {
1020
1100
  }
1021
1101
 
1022
1102
  if (borderVisible && this.#closedBorderBox) {
1023
- const bottomFillWidth = Math.max(0, width - borderWidth * 2);
1103
+ const bottomFillWidth = Math.max(0, renderWidth - borderWidth * 2);
1024
1104
  const bottomLeftClosed = this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(paddingX)}`);
1025
1105
  const bottomRightClosed = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.bottomRight}`);
1026
1106
  result.push(bottomLeftClosed + horizontal.repeat(bottomFillWidth) + bottomRightClosed);
@@ -1028,10 +1108,15 @@ export class Editor implements Component, Focusable {
1028
1108
 
1029
1109
  // Add autocomplete list if active
1030
1110
  if (this.#autocompleteState && this.#autocompleteList) {
1031
- const autocompleteResult = this.#autocompleteList.render(width);
1111
+ const autocompleteResult = this.#autocompleteList.render(renderWidth);
1032
1112
  result.push(...autocompleteResult);
1033
1113
  }
1034
1114
 
1115
+ if (rightGutterWidth > 0) {
1116
+ const rightGutter = padding(rightGutterWidth);
1117
+ return result.map(line => line + rightGutter);
1118
+ }
1119
+
1035
1120
  return result;
1036
1121
  }
1037
1122
 
@@ -1132,6 +1217,7 @@ export class Editor implements Component, Focusable {
1132
1217
  );
1133
1218
 
1134
1219
  this.#state.lines = result.lines;
1220
+ this.#bumpDocumentVersion();
1135
1221
  this.#state.cursorLine = result.cursorLine;
1136
1222
  this.#setCursorCol(result.cursorCol);
1137
1223
 
@@ -1171,6 +1257,7 @@ export class Editor implements Component, Focusable {
1171
1257
  );
1172
1258
 
1173
1259
  this.#state.lines = result.lines;
1260
+ this.#bumpDocumentVersion();
1174
1261
  this.#state.cursorLine = result.cursorLine;
1175
1262
  this.#setCursorCol(result.cursorCol);
1176
1263
  result.onApplied?.();
@@ -1192,6 +1279,7 @@ export class Editor implements Component, Focusable {
1192
1279
  );
1193
1280
 
1194
1281
  this.#state.lines = result.lines;
1282
+ this.#bumpDocumentVersion();
1195
1283
  this.#state.cursorLine = result.cursorLine;
1196
1284
  this.#setCursorCol(result.cursorCol);
1197
1285
 
@@ -1312,6 +1400,7 @@ export class Editor implements Component, Focusable {
1312
1400
  syncResult.prefix,
1313
1401
  );
1314
1402
  this.#state.lines = result.lines;
1403
+ this.#bumpDocumentVersion();
1315
1404
  this.#state.cursorLine = result.cursorLine;
1316
1405
  this.#setCursorCol(result.cursorCol);
1317
1406
  result.onApplied?.();
@@ -1445,119 +1534,175 @@ export class Editor implements Component, Focusable {
1445
1534
  return this.#wrappedLineCache.length;
1446
1535
  }
1447
1536
 
1448
- #layoutText(contentWidth: number): LayoutLine[] {
1537
+ #makeLayoutCacheKey(contentWidth: number): LayoutCacheKey {
1538
+ return {
1539
+ docVersion: this.#docVersion,
1540
+ contentWidth,
1541
+ optionsKey: JSON.stringify({
1542
+ borderVisible: this.#borderVisible,
1543
+ borderStyle: this.#borderStyle,
1544
+ closedBorderBox: this.#closedBorderBox,
1545
+ inputPrefix: this.#inputPrefix,
1546
+ inputPrefixWidth: this.#inputPrefixWidth,
1547
+ placeholder: this.#placeholder,
1548
+ promptGutter: this.#promptGutter,
1549
+ useTerminalCursor: this.#useTerminalCursor,
1550
+ cursorOverride: this.cursorOverride,
1551
+ cursorOverrideWidth: this.cursorOverrideWidth,
1552
+ autocompleteState: this.#autocompleteState,
1553
+ autocompletePrefix: this.#autocompletePrefix,
1554
+ autocompleteHint: this.#autocompleteList?.getSelectedItem()?.hint,
1555
+ }),
1556
+ };
1557
+ }
1558
+
1559
+ #layoutLine(text: string, logicalLine: number, hasCursor: boolean, cursorPos?: number): LayoutLine {
1560
+ __editorPerfCounters.visibleWidthMeasurements += 1;
1561
+ return {
1562
+ text,
1563
+ visibleWidth: visibleWidth(text),
1564
+ logicalLine,
1565
+ hasCursor,
1566
+ cursorPos,
1567
+ };
1568
+ }
1569
+
1570
+ #layoutLogicalLine(lineIndex: number, contentWidth: number): LayoutLine[] {
1571
+ __editorPerfCounters.layoutLogicalLinesProcessed += 1;
1572
+ const line = this.#state.lines[lineIndex] || "";
1573
+ const isCurrentLine = lineIndex === this.#state.cursorLine;
1574
+ const wrappedLine = this.#getWrappedLine(lineIndex, contentWidth);
1449
1575
  const layoutLines: LayoutLine[] = [];
1450
1576
 
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
- });
1577
+ if (wrappedLine.width <= contentWidth) {
1578
+ layoutLines.push(
1579
+ this.#layoutLine(line, lineIndex, isCurrentLine, isCurrentLine ? this.#state.cursorCol : undefined),
1580
+ );
1461
1581
  return layoutLines;
1462
1582
  }
1463
1583
 
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;
1584
+ const chunks = wrappedLine.chunks;
1585
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1586
+ const chunk = chunks[chunkIndex];
1587
+ if (!chunk) continue;
1487
1588
 
1488
- for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1489
- const chunk = chunks[chunkIndex];
1490
- if (!chunk) continue;
1589
+ const cursorPos = this.#state.cursorCol;
1590
+ const isLastChunk = chunkIndex === chunks.length - 1;
1591
+ let hasCursorInChunk = false;
1592
+ let adjustedCursorPos = 0;
1491
1593
 
1492
- const cursorPos = this.#state.cursorCol;
1493
- const isLastChunk = chunkIndex === chunks.length - 1;
1494
-
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;
1500
-
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
- }
1594
+ if (isCurrentLine) {
1595
+ if (isLastChunk) {
1596
+ hasCursorInChunk = cursorPos >= chunk.startIndex;
1597
+ adjustedCursorPos = cursorPos - chunk.startIndex;
1598
+ } else {
1599
+ hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
1600
+ if (hasCursorInChunk) {
1601
+ adjustedCursorPos = cursorPos - chunk.startIndex;
1602
+ if (adjustedCursorPos > chunk.text.length) {
1603
+ adjustedCursorPos = chunk.text.length;
1517
1604
  }
1518
1605
  }
1606
+ }
1607
+ }
1519
1608
 
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
- });
1609
+ if (hasCursorInChunk) {
1610
+ let displayChunkText = chunk.text;
1611
+ let displayCursorPos = adjustedCursorPos;
1612
+ if (displayCursorPos > displayChunkText.length) {
1613
+ let hiddenWhitespaceWidth = displayCursorPos - displayChunkText.length;
1614
+ const displayChunkWidth = visibleWidth(displayChunkText);
1615
+ __editorPerfCounters.visibleWidthMeasurements += 1;
1616
+ if (displayChunkWidth + hiddenWhitespaceWidth <= contentWidth) {
1617
+ displayChunkText += padding(hiddenWhitespaceWidth);
1550
1618
  } else {
1551
- layoutLines.push({
1552
- text: chunk.text,
1553
- hasCursor: false,
1554
- });
1619
+ layoutLines.push(this.#layoutLine(displayChunkText, lineIndex, false));
1620
+ hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
1621
+ while (hiddenWhitespaceWidth > contentWidth) {
1622
+ layoutLines.push(this.#layoutLine(padding(contentWidth), lineIndex, false));
1623
+ hiddenWhitespaceWidth -= contentWidth;
1624
+ }
1625
+ displayChunkText = padding(hiddenWhitespaceWidth);
1626
+ displayCursorPos = hiddenWhitespaceWidth;
1555
1627
  }
1556
1628
  }
1629
+ layoutLines.push(this.#layoutLine(displayChunkText, lineIndex, true, displayCursorPos));
1630
+ } else {
1631
+ layoutLines.push(this.#layoutLine(chunk.text, lineIndex, false));
1632
+ }
1633
+ }
1634
+
1635
+ return layoutLines;
1636
+ }
1637
+
1638
+ #sameLayoutCacheKey(a: LayoutCacheKey, b: LayoutCacheKey): boolean {
1639
+ return a.docVersion === b.docVersion && a.contentWidth === b.contentWidth && a.optionsKey === b.optionsKey;
1640
+ }
1641
+
1642
+ #replaceCachedLogicalLine(cache: LayoutCache, lineIndex: number, contentWidth: number): void {
1643
+ const start = cache.lineStarts[lineIndex] ?? cache.lines.length;
1644
+ const oldCount = cache.lineCounts[lineIndex] ?? 0;
1645
+ const replacement = this.#layoutLogicalLine(lineIndex, contentWidth);
1646
+ cache.lines.splice(start, oldCount, ...replacement);
1647
+ cache.lineCounts[lineIndex] = replacement.length;
1648
+ const delta = replacement.length - oldCount;
1649
+ if (delta !== 0) {
1650
+ for (let i = lineIndex + 1; i < cache.lineStarts.length; i++) {
1651
+ cache.lineStarts[i] = (cache.lineStarts[i] ?? 0) + delta;
1652
+ }
1653
+ }
1654
+ }
1655
+
1656
+ #patchCursorInCachedLayout(cache: LayoutCache, contentWidth: number): LayoutLine[] {
1657
+ const previousLine = cache.cursorLine;
1658
+ const currentLine = this.#state.cursorLine;
1659
+ this.#replaceCachedLogicalLine(cache, previousLine, contentWidth);
1660
+ if (currentLine !== previousLine) {
1661
+ this.#replaceCachedLogicalLine(cache, currentLine, contentWidth);
1662
+ }
1663
+ cache.cursorLine = currentLine;
1664
+ cache.cursorCol = this.#state.cursorCol;
1665
+ return cache.lines;
1666
+ }
1667
+
1668
+ #layoutText(contentWidth: number): LayoutLine[] {
1669
+ __editorPerfCounters.layoutTextInvocations += 1;
1670
+ const key = this.#makeLayoutCacheKey(contentWidth);
1671
+ const cached = this.#layoutCache;
1672
+ if (cached && this.#sameLayoutCacheKey(cached.key, key)) {
1673
+ if (cached.cursorLine === this.#state.cursorLine && cached.cursorCol === this.#state.cursorCol) {
1674
+ return cached.lines;
1675
+ }
1676
+ return this.#patchCursorInCachedLayout(cached, contentWidth);
1677
+ }
1678
+
1679
+ const layoutLines: LayoutLine[] = [];
1680
+ const lineStarts: number[] = [];
1681
+ const lineCounts: number[] = [];
1682
+
1683
+ if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
1684
+ this.#wrappedLineCache.length = this.#state.lines.length;
1685
+ lineStarts[0] = 0;
1686
+ lineCounts[0] = 1;
1687
+ layoutLines.push(this.#layoutLine("", 0, true, 0));
1688
+ } else {
1689
+ for (let i = 0; i < this.#state.lines.length; i++) {
1690
+ lineStarts[i] = layoutLines.length;
1691
+ const logicalLayout = this.#layoutLogicalLine(i, contentWidth);
1692
+ lineCounts[i] = logicalLayout.length;
1693
+ layoutLines.push(...logicalLayout);
1557
1694
  }
1558
1695
  }
1559
1696
 
1560
1697
  this.#wrappedLineCache.length = this.#state.lines.length;
1698
+ this.#layoutCache = {
1699
+ key,
1700
+ cursorLine: this.#state.cursorLine,
1701
+ cursorCol: this.#state.cursorCol,
1702
+ lines: layoutLines,
1703
+ lineStarts,
1704
+ lineCounts,
1705
+ };
1561
1706
  return layoutLines;
1562
1707
  }
1563
1708
 
@@ -1629,6 +1774,7 @@ export class Editor implements Component, Focusable {
1629
1774
  this.#resetKillSequence();
1630
1775
  this.#preferredVisualCol = null;
1631
1776
  this.#state.lines[this.#state.cursorLine] = beforeTransient + afterTransient;
1777
+ this.#bumpDocumentVersion();
1632
1778
  this.#setCursorCol(transientStartCol);
1633
1779
 
1634
1780
  while (true) {
@@ -1677,18 +1823,7 @@ export class Editor implements Component, Focusable {
1677
1823
  /** Insert text at the current cursor position */
1678
1824
  insertText(text: string): void {
1679
1825
  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
- }
1826
+ this.#insertTextAtCursor(text);
1692
1827
  }
1693
1828
 
1694
1829
  // All the editor methods from before...
@@ -1732,9 +1867,8 @@ export class Editor implements Component, Focusable {
1732
1867
 
1733
1868
  // Check if we should trigger or update autocomplete
1734
1869
  if (!this.#autocompleteState) {
1735
- // Auto-trigger for "/" at the start of a submitted command.
1736
- // Inline skill autocomplete starts after the token becomes "/skill...".
1737
- if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
1870
+ // Auto-trigger for slash command tokens.
1871
+ if (char === "/" && (this.#isAtStartOfSubmittedMessage() || this.#isInSlashTokenContext())) {
1738
1872
  this.#tryTriggerAutocomplete();
1739
1873
  }
1740
1874
  // Auto-trigger for "@" file reference (fuzzy search)
@@ -1781,6 +1915,11 @@ export class Editor implements Component, Focusable {
1781
1915
  this.#historyIndex = -1; // Exit history browsing mode
1782
1916
  this.#resetKillSequence();
1783
1917
  this.#recordUndoState();
1918
+ const hadAutocomplete = this.#autocompleteState !== null;
1919
+ this.#cancelAutocomplete();
1920
+ if (hadAutocomplete) {
1921
+ this.onAutocompleteUpdate?.();
1922
+ }
1784
1923
 
1785
1924
  this.#withUndoSuspended(() => {
1786
1925
  // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
@@ -1808,21 +1947,12 @@ export class Editor implements Component, Focusable {
1808
1947
  const tabExpandedText = cleanText.replace(/\t/g, " ");
1809
1948
 
1810
1949
  // Filter out non-printable characters except newlines
1811
- let filteredText = tabExpandedText
1950
+ const filteredText = tabExpandedText
1812
1951
  .split("")
1813
1952
  .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1814
1953
  .join("");
1815
1954
 
1816
- // If pasting a file path (starts with /, ~, or .) and the character before
1817
- // the cursor is a word character, prepend a space for better readability
1818
- if (/^[/~.]/.test(filteredText)) {
1819
- const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1820
- const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
1821
- if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
1822
- filteredText = ` ${filteredText}`;
1823
- }
1824
- }
1825
-
1955
+ if (filteredText.length === 0) return;
1826
1956
  // Split into lines
1827
1957
  const pastedLines = filteredText.split("\n");
1828
1958
 
@@ -1844,15 +1974,10 @@ export class Editor implements Component, Focusable {
1844
1974
  return;
1845
1975
  }
1846
1976
 
1847
- if (pastedLines.length === 1) {
1848
- // Single line - insert character by character to trigger autocomplete
1849
- for (const char of filteredText) {
1850
- this.#insertCharacter(char);
1851
- }
1852
- return;
1853
- }
1854
-
1855
- // Multi-line paste - use insertTextAtCursor for proper handling
1977
+ // Paste is literal input, not typed input. Insert atomically so leading
1978
+ // trigger characters such as "/", "#", "@", ":", or path-like text do
1979
+ // not open or update autocomplete lists while preserving normal typed
1980
+ // trigger behavior.
1856
1981
  this.#insertTextAtCursor(filteredText);
1857
1982
  });
1858
1983
  }
@@ -1897,6 +2022,7 @@ export class Editor implements Component, Focusable {
1897
2022
  const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
1898
2023
 
1899
2024
  this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
2025
+ this.#bumpDocumentVersion();
1900
2026
  this.#pastes.clear();
1901
2027
  this.#pasteCounter = 0;
1902
2028
  this.#historyIndex = -1;
@@ -2088,6 +2214,7 @@ export class Editor implements Component, Focusable {
2088
2214
  #recordUndoState(): void {
2089
2215
  if (this.#suspendUndo) return;
2090
2216
  this.#undoStack.push(structuredClone(this.#state));
2217
+ this.#bumpDocumentVersion();
2091
2218
  }
2092
2219
 
2093
2220
  #applyUndo(): void {
@@ -2098,6 +2225,7 @@ export class Editor implements Component, Focusable {
2098
2225
  this.#resetKillSequence();
2099
2226
  this.#preferredVisualCol = null;
2100
2227
  Object.assign(this.#state, snapshot);
2228
+ this.#bumpDocumentVersion();
2101
2229
 
2102
2230
  if (this.onChange) {
2103
2231
  this.onChange(this.getText());
@@ -2603,6 +2731,12 @@ export class Editor implements Component, Focusable {
2603
2731
  : this.#state.cursorCol - 1
2604
2732
  : undefined;
2605
2733
 
2734
+ // Backward from column 0: lastIndexOf clamps a negative position to 0,
2735
+ // which would match the character under the cursor instead of skipping it
2736
+ if (!isForward && searchFrom !== undefined && searchFrom < 0) {
2737
+ continue;
2738
+ }
2739
+
2606
2740
  const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
2607
2741
 
2608
2742
  if (idx !== -1) {
@@ -2655,12 +2789,11 @@ export class Editor implements Component, Focusable {
2655
2789
  #getSlashTokenBeforeCursor(): string | null {
2656
2790
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2657
2791
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2658
- const match = beforeCursor.match(/(?:^|\s)(\/[^\s]*)$/);
2659
- return match?.[1] ?? null;
2792
+ return extractSlashCommandTokenPrefix(beforeCursor);
2660
2793
  }
2661
2794
 
2662
2795
  #isInSlashTokenContext(): boolean {
2663
- return this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true;
2796
+ return this.#getSlashTokenBeforeCursor() !== null;
2664
2797
  }
2665
2798
 
2666
2799
  #isSlashCommandNameAutocompleteSelection(): boolean {
@@ -2744,7 +2877,10 @@ export class Editor implements Component, Focusable {
2744
2877
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2745
2878
 
2746
2879
  // Check if we're in a slash command context
2747
- if (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) {
2880
+ if (
2881
+ (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) ||
2882
+ this.#isInSlashTokenContext()
2883
+ ) {
2748
2884
  this.#handleSlashCommandCompletion();
2749
2885
  } else {
2750
2886
  this.#forceFileAutocomplete(true);
@@ -2793,6 +2929,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2793
2929
  );
2794
2930
 
2795
2931
  this.#state.lines = result.lines;
2932
+ this.#bumpDocumentVersion();
2796
2933
  this.#state.cursorLine = result.cursorLine;
2797
2934
  this.#setCursorCol(result.cursorCol);
2798
2935