@sayknow-cli/tui 0.3.7 → 0.3.9

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;
1487
-
1488
- for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1489
- const chunk = chunks[chunkIndex];
1490
- if (!chunk) continue;
1584
+ const chunks = wrappedLine.chunks;
1585
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1586
+ const chunk = chunks[chunkIndex];
1587
+ if (!chunk) continue;
1491
1588
 
1492
- const cursorPos = this.#state.cursorCol;
1493
- const isLastChunk = chunkIndex === chunks.length - 1;
1589
+ const cursorPos = this.#state.cursorCol;
1590
+ const isLastChunk = chunkIndex === chunks.length - 1;
1591
+ let hasCursorInChunk = false;
1592
+ let adjustedCursorPos = 0;
1494
1593
 
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)
@@ -1780,49 +1914,48 @@ export class Editor implements Component, Focusable {
1780
1914
  #handlePaste(pastedText: string): void {
1781
1915
  this.#historyIndex = -1; // Exit history browsing mode
1782
1916
  this.#resetKillSequence();
1783
- this.#recordUndoState();
1917
+ const hadAutocomplete = this.#autocompleteState !== null;
1918
+ this.#cancelAutocomplete();
1919
+ if (hadAutocomplete) {
1920
+ this.onAutocompleteUpdate?.();
1921
+ }
1784
1922
 
1785
- this.#withUndoSuspended(() => {
1786
- // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1787
- // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1788
- // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1789
- // per-char filter below preserves newlines instead of stripping ESC and
1790
- // leaking the printable tail (e.g. "[106;5u") into the editor.
1791
- const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1792
- const cp = Number(code);
1793
- if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1794
- if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1795
- return match;
1796
- });
1923
+ // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1924
+ // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1925
+ // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1926
+ // per-char filter below preserves newlines instead of stripping ESC and
1927
+ // leaking the printable tail (e.g. "[106;5u") into the editor.
1928
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1929
+ const cp = Number(code);
1930
+ if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1931
+ if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1932
+ return match;
1933
+ });
1797
1934
 
1798
- // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
1799
- // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
1800
- // land in the buffer as the same precomposed syllables a terminal
1801
- // renders — without this, cursor column accounting drifts by
1802
- // `(NFD cells − NFC cells)` and the visible glyph desyncs from the
1803
- // hardware cursor. Matches the `Input` component's prior fix; this
1804
- // is the same fix on the real SKC prompt component (`Editor`).
1805
- const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
1806
-
1807
- // Convert tabs to spaces (4 spaces per tab)
1808
- const tabExpandedText = cleanText.replace(/\t/g, " ");
1809
-
1810
- // Filter out non-printable characters except newlines
1811
- let filteredText = tabExpandedText
1812
- .split("")
1813
- .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1814
- .join("");
1815
-
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
- }
1935
+ // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
1936
+ // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
1937
+ // land in the buffer as the same precomposed syllables a terminal
1938
+ // renders — without this, cursor column accounting drifts by
1939
+ // `(NFD cells − NFC cells)` and the visible glyph desyncs from the
1940
+ // hardware cursor. Matches the `Input` component's prior fix; this
1941
+ // is the same fix on the real SKC prompt component (`Editor`).
1942
+ const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
1943
+
1944
+ // Convert tabs to spaces (4 spaces per tab)
1945
+ const tabExpandedText = cleanText.replace(/\t/g, " ");
1946
+
1947
+ // Filter out non-printable characters except newlines
1948
+ const filteredText = tabExpandedText
1949
+ .split("")
1950
+ .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1951
+ .join("");
1952
+
1953
+ // Nothing survived filtering: the buffer is untouched, so don't record
1954
+ // an undo snapshot a no-op entry would make the next undo appear dead.
1955
+ if (filteredText.length === 0) return;
1825
1956
 
1957
+ this.#recordUndoState();
1958
+ this.#withUndoSuspended(() => {
1826
1959
  // Split into lines
1827
1960
  const pastedLines = filteredText.split("\n");
1828
1961
 
@@ -1844,15 +1977,10 @@ export class Editor implements Component, Focusable {
1844
1977
  return;
1845
1978
  }
1846
1979
 
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
1980
+ // Paste is literal input, not typed input. Insert atomically so leading
1981
+ // trigger characters such as "/", "#", "@", ":", or path-like text do
1982
+ // not open or update autocomplete lists while preserving normal typed
1983
+ // trigger behavior.
1856
1984
  this.#insertTextAtCursor(filteredText);
1857
1985
  });
1858
1986
  }
@@ -1897,6 +2025,7 @@ export class Editor implements Component, Focusable {
1897
2025
  const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
1898
2026
 
1899
2027
  this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
2028
+ this.#bumpDocumentVersion();
1900
2029
  this.#pastes.clear();
1901
2030
  this.#pasteCounter = 0;
1902
2031
  this.#historyIndex = -1;
@@ -2088,6 +2217,7 @@ export class Editor implements Component, Focusable {
2088
2217
  #recordUndoState(): void {
2089
2218
  if (this.#suspendUndo) return;
2090
2219
  this.#undoStack.push(structuredClone(this.#state));
2220
+ this.#bumpDocumentVersion();
2091
2221
  }
2092
2222
 
2093
2223
  #applyUndo(): void {
@@ -2098,6 +2228,7 @@ export class Editor implements Component, Focusable {
2098
2228
  this.#resetKillSequence();
2099
2229
  this.#preferredVisualCol = null;
2100
2230
  Object.assign(this.#state, snapshot);
2231
+ this.#bumpDocumentVersion();
2101
2232
 
2102
2233
  if (this.onChange) {
2103
2234
  this.onChange(this.getText());
@@ -2603,6 +2734,12 @@ export class Editor implements Component, Focusable {
2603
2734
  : this.#state.cursorCol - 1
2604
2735
  : undefined;
2605
2736
 
2737
+ // Backward from column 0: lastIndexOf clamps a negative position to 0,
2738
+ // which would match the character under the cursor instead of skipping it
2739
+ if (!isForward && searchFrom !== undefined && searchFrom < 0) {
2740
+ continue;
2741
+ }
2742
+
2606
2743
  const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
2607
2744
 
2608
2745
  if (idx !== -1) {
@@ -2655,12 +2792,11 @@ export class Editor implements Component, Focusable {
2655
2792
  #getSlashTokenBeforeCursor(): string | null {
2656
2793
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2657
2794
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2658
- const match = beforeCursor.match(/(?:^|\s)(\/[^\s]*)$/);
2659
- return match?.[1] ?? null;
2795
+ return extractSlashCommandTokenPrefix(beforeCursor);
2660
2796
  }
2661
2797
 
2662
2798
  #isInSlashTokenContext(): boolean {
2663
- return this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true;
2799
+ return this.#getSlashTokenBeforeCursor() !== null;
2664
2800
  }
2665
2801
 
2666
2802
  #isSlashCommandNameAutocompleteSelection(): boolean {
@@ -2744,7 +2880,10 @@ export class Editor implements Component, Focusable {
2744
2880
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2745
2881
 
2746
2882
  // Check if we're in a slash command context
2747
- if (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) {
2883
+ if (
2884
+ (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) ||
2885
+ this.#isInSlashTokenContext()
2886
+ ) {
2748
2887
  this.#handleSlashCommandCompletion();
2749
2888
  } else {
2750
2889
  this.#forceFileAutocomplete(true);
@@ -2793,6 +2932,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2793
2932
  );
2794
2933
 
2795
2934
  this.#state.lines = result.lines;
2935
+ this.#bumpDocumentVersion();
2796
2936
  this.#state.cursorLine = result.cursorLine;
2797
2937
  this.#setCursorCol(result.cursorCol);
2798
2938