@sayknow-cli/tui 0.3.6 → 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.
Files changed (43) hide show
  1. package/dist/types/animation-scheduler.d.ts +13 -0
  2. package/dist/types/autocomplete.d.ts +83 -0
  3. package/dist/types/bracketed-paste.d.ts +26 -0
  4. package/dist/types/components/box.d.ts +20 -0
  5. package/dist/types/components/cancellable-loader.d.ts +21 -0
  6. package/dist/types/components/editor.d.ts +126 -0
  7. package/dist/types/components/image.d.ts +16 -0
  8. package/dist/types/components/input.d.ts +16 -0
  9. package/dist/types/components/loader.d.ts +23 -0
  10. package/dist/types/components/markdown.d.ts +77 -0
  11. package/dist/types/components/select-list.d.ts +46 -0
  12. package/dist/types/components/settings-list.d.ts +39 -0
  13. package/dist/types/components/spacer.d.ts +11 -0
  14. package/dist/types/components/tab-bar.d.ts +56 -0
  15. package/dist/types/components/text.d.ts +13 -0
  16. package/dist/types/components/truncated-text.d.ts +10 -0
  17. package/dist/types/editor-component.d.ts +36 -0
  18. package/dist/types/fuzzy.d.ts +15 -0
  19. package/dist/types/index.d.ts +27 -0
  20. package/dist/types/keybindings.d.ts +201 -0
  21. package/dist/types/keys.d.ts +208 -0
  22. package/dist/types/kill-ring.d.ts +27 -0
  23. package/dist/types/metrics.d.ts +85 -0
  24. package/dist/types/stdin-buffer.d.ts +50 -0
  25. package/dist/types/symbols.d.ts +23 -0
  26. package/dist/types/terminal-capabilities.d.ts +75 -0
  27. package/dist/types/terminal.d.ts +88 -0
  28. package/dist/types/ttyid.d.ts +9 -0
  29. package/dist/types/tui.d.ts +206 -0
  30. package/dist/types/utils.d.ts +87 -0
  31. package/package.json +10 -9
  32. package/src/animation-scheduler.ts +99 -0
  33. package/src/autocomplete.ts +119 -96
  34. package/src/components/editor.ts +310 -128
  35. package/src/components/input.ts +2 -1
  36. package/src/components/loader.ts +36 -37
  37. package/src/components/markdown.ts +79 -2
  38. package/src/components/select-list.ts +8 -1
  39. package/src/index.ts +1 -0
  40. package/src/stdin-buffer.ts +89 -11
  41. package/src/terminal.ts +44 -8
  42. package/src/tui.ts +362 -64
  43. package/src/utils.ts +77 -11
@@ -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();
@@ -450,6 +486,11 @@ export class Editor implements Component, Focusable {
450
486
  onChange?: (text: string) => void;
451
487
  onAutocompleteCancel?: () => void;
452
488
  onTabDeclined?: (text: string) => void;
489
+ /**
490
+ * Called before Tab opens/applies autocomplete. Return true to consume Tab
491
+ * for app-level behavior (for example, queueing a draft while a turn runs).
492
+ */
493
+ onTab?: (text: string) => boolean | undefined;
453
494
  disableSubmit: boolean = false;
454
495
 
455
496
  // Custom top border (for status line integration)
@@ -458,9 +499,25 @@ export class Editor implements Component, Focusable {
458
499
  #borderStyle: EditorBorderStyle = "round";
459
500
  #closedBorderBox = false;
460
501
 
502
+ #disposeTabWidthListener?: () => void;
503
+
461
504
  constructor(theme: EditorTheme) {
462
505
  this.#theme = theme;
463
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;
464
521
  }
465
522
 
466
523
  setAutocompleteProvider(provider: AutocompleteProvider): void {
@@ -482,6 +539,7 @@ export class Editor implements Component, Focusable {
482
539
  */
483
540
  setTopBorder(content: EditorTopBorder | undefined): void {
484
541
  this.#topBorderContent = content;
542
+ this.#invalidateLayoutCache();
485
543
  }
486
544
 
487
545
  /**
@@ -489,38 +547,46 @@ export class Editor implements Component, Focusable {
489
547
  */
490
548
  setBorderVisible(borderVisible: boolean): void {
491
549
  this.#borderVisible = borderVisible;
550
+ this.#invalidateLayoutCache();
492
551
  }
493
552
 
494
553
  setBorderStyle(borderStyle: EditorBorderStyle): void {
495
554
  this.#borderStyle = borderStyle;
555
+ this.#invalidateLayoutCache();
496
556
  }
497
557
 
498
558
  setClosedBorderBox(closedBorderBox: boolean): void {
499
559
  this.#closedBorderBox = closedBorderBox;
560
+ this.#invalidateLayoutCache();
500
561
  }
501
562
 
502
563
  setPromptGutter(promptGutter: string | undefined): void {
503
564
  this.#promptGutter = promptGutter;
565
+ this.#invalidateLayoutCache();
504
566
  }
505
567
 
506
568
  setInputPrefix(inputPrefix: string | undefined): void {
507
569
  this.#inputPrefix = inputPrefix;
508
570
  this.#inputPrefixWidth = inputPrefix ? visibleWidth(inputPrefix) : 0;
571
+ this.#invalidateLayoutCache();
509
572
  }
510
573
 
511
574
  setPlaceholder(placeholder: string | undefined): void {
512
575
  const trimmed = placeholder?.trim();
513
576
  this.#placeholder = trimmed ? trimmed : undefined;
577
+ this.#invalidateLayoutCache();
514
578
  }
515
579
 
516
580
  /**
517
581
  * Get the available width for top border content given a total terminal width.
518
- * Accounts for the border characters and horizontal padding when visible.
582
+ * Accounts for right gutter, border characters, and horizontal padding when visible.
519
583
  */
520
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);
521
587
  const paddingX = this.#getEditorPaddingX();
522
588
  const borderWidth = this.#getHorizontalChromeWidth(paddingX);
523
- return Math.max(0, terminalWidth - borderWidth * 2);
589
+ return Math.max(0, renderWidth - borderWidth * 2);
524
590
  }
525
591
 
526
592
  /**
@@ -528,6 +594,7 @@ export class Editor implements Component, Focusable {
528
594
  */
529
595
  setUseTerminalCursor(useTerminalCursor: boolean): void {
530
596
  this.#useTerminalCursor = useTerminalCursor;
597
+ this.#invalidateLayoutCache();
531
598
  }
532
599
 
533
600
  getUseTerminalCursor(): boolean {
@@ -542,6 +609,11 @@ export class Editor implements Component, Focusable {
542
609
 
543
610
  setPaddingX(paddingX: number): void {
544
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;
545
617
  }
546
618
 
547
619
  getAutocompleteMaxVisible(): number {
@@ -631,10 +703,21 @@ export class Editor implements Component, Focusable {
631
703
  this.onChange(this.getText());
632
704
  }
633
705
  this.#wrappedLineCache.length = 0;
706
+ this.#bumpDocumentVersion();
634
707
  }
635
708
 
636
709
  invalidate(): void {
637
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;
638
721
  }
639
722
 
640
723
  #getEditorPaddingX(): number {
@@ -780,12 +863,14 @@ export class Editor implements Component, Focusable {
780
863
  }
781
864
 
782
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);
783
868
  const paddingX = this.#getEditorPaddingX();
784
869
  const borderVisible = this.#borderVisible;
785
- const promptGutter = this.#getPromptGutter(width, paddingX);
786
- const contentAreaWidth = this.#getContentWidth(width, paddingX);
870
+ const promptGutter = this.#getPromptGutter(renderWidth, paddingX);
871
+ const contentAreaWidth = this.#getContentWidth(renderWidth, paddingX);
787
872
  const inputPrefixWidth = this.#inputPrefixWidth;
788
- const layoutWidth = Math.max(1, this.#getLayoutWidth(width, paddingX) - inputPrefixWidth);
873
+ const layoutWidth = Math.max(1, this.#getLayoutWidth(renderWidth, paddingX) - inputPrefixWidth);
789
874
  this.#lastLayoutWidth = layoutWidth;
790
875
 
791
876
  // Box-drawing characters for the configured input box shape.
@@ -806,7 +891,7 @@ export class Editor implements Component, Focusable {
806
891
 
807
892
  if (borderVisible) {
808
893
  // Render top border: ╭─ [status content] ────────────────╮
809
- const topFillWidth = Math.max(0, width - borderWidth * 2);
894
+ const topFillWidth = Math.max(0, renderWidth - borderWidth * 2);
810
895
  if (this.#topBorderContent) {
811
896
  const { content, width: statusWidth } = this.#topBorderContent;
812
897
  if (statusWidth <= topFillWidth) {
@@ -838,7 +923,7 @@ export class Editor implements Component, Focusable {
838
923
  for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) {
839
924
  const layoutLine = visibleLayoutLines[visibleIndex]!;
840
925
  let displayText = layoutLine.text;
841
- let displayWidth = visibleWidth(layoutLine.text);
926
+ let displayWidth = layoutLine.visibleWidth;
842
927
  let cursorInPadding = false;
843
928
  const absoluteVisibleIndex = this.#scrollOffset + visibleIndex;
844
929
  const showPromptGutter = promptGutter !== undefined && visibleIndex === 0;
@@ -1015,7 +1100,7 @@ export class Editor implements Component, Focusable {
1015
1100
  }
1016
1101
 
1017
1102
  if (borderVisible && this.#closedBorderBox) {
1018
- const bottomFillWidth = Math.max(0, width - borderWidth * 2);
1103
+ const bottomFillWidth = Math.max(0, renderWidth - borderWidth * 2);
1019
1104
  const bottomLeftClosed = this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(paddingX)}`);
1020
1105
  const bottomRightClosed = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.bottomRight}`);
1021
1106
  result.push(bottomLeftClosed + horizontal.repeat(bottomFillWidth) + bottomRightClosed);
@@ -1023,10 +1108,15 @@ export class Editor implements Component, Focusable {
1023
1108
 
1024
1109
  // Add autocomplete list if active
1025
1110
  if (this.#autocompleteState && this.#autocompleteList) {
1026
- const autocompleteResult = this.#autocompleteList.render(width);
1111
+ const autocompleteResult = this.#autocompleteList.render(renderWidth);
1027
1112
  result.push(...autocompleteResult);
1028
1113
  }
1029
1114
 
1115
+ if (rightGutterWidth > 0) {
1116
+ const rightGutter = padding(rightGutterWidth);
1117
+ return result.map(line => line + rightGutter);
1118
+ }
1119
+
1030
1120
  return result;
1031
1121
  }
1032
1122
 
@@ -1080,6 +1170,10 @@ export class Editor implements Component, Focusable {
1080
1170
  return;
1081
1171
  }
1082
1172
 
1173
+ if (kb.matches(data, "tui.input.tab") && this.onTab?.(this.getText())) {
1174
+ return;
1175
+ }
1176
+
1083
1177
  // Handle autocomplete special keys first (but don't block other input)
1084
1178
  if (this.#autocompleteState && this.#autocompleteList) {
1085
1179
  // Escape - cancel autocomplete
@@ -1123,6 +1217,7 @@ export class Editor implements Component, Focusable {
1123
1217
  );
1124
1218
 
1125
1219
  this.#state.lines = result.lines;
1220
+ this.#bumpDocumentVersion();
1126
1221
  this.#state.cursorLine = result.cursorLine;
1127
1222
  this.#setCursorCol(result.cursorCol);
1128
1223
 
@@ -1147,7 +1242,7 @@ export class Editor implements Component, Focusable {
1147
1242
  // Check for stale autocomplete state due to debounce
1148
1243
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1149
1244
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1150
- if (currentTextBeforeCursor !== this.#autocompletePrefix) {
1245
+ if (!currentTextBeforeCursor.endsWith(this.#autocompletePrefix)) {
1151
1246
  // Autocomplete is stale - cancel and fall through to normal submission
1152
1247
  this.#cancelAutocomplete();
1153
1248
  } else {
@@ -1162,6 +1257,7 @@ export class Editor implements Component, Focusable {
1162
1257
  );
1163
1258
 
1164
1259
  this.#state.lines = result.lines;
1260
+ this.#bumpDocumentVersion();
1165
1261
  this.#state.cursorLine = result.cursorLine;
1166
1262
  this.#setCursorCol(result.cursorCol);
1167
1263
  result.onApplied?.();
@@ -1183,6 +1279,7 @@ export class Editor implements Component, Focusable {
1183
1279
  );
1184
1280
 
1185
1281
  this.#state.lines = result.lines;
1282
+ this.#bumpDocumentVersion();
1186
1283
  this.#state.cursorLine = result.cursorLine;
1187
1284
  this.#setCursorCol(result.cursorCol);
1188
1285
 
@@ -1285,8 +1382,8 @@ export class Editor implements Component, Focusable {
1285
1382
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1286
1383
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1287
1384
  if (
1288
- textBeforeCursor.startsWith("/") &&
1289
- this.#isInSubmittedSlashCommandContext() &&
1385
+ (this.#isInSubmittedSlashCommandContext() ||
1386
+ this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true) &&
1290
1387
  this.#autocompleteProvider?.trySyncSlashCompletion
1291
1388
  ) {
1292
1389
  const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
@@ -1303,6 +1400,7 @@ export class Editor implements Component, Focusable {
1303
1400
  syncResult.prefix,
1304
1401
  );
1305
1402
  this.#state.lines = result.lines;
1403
+ this.#bumpDocumentVersion();
1306
1404
  this.#state.cursorLine = result.cursorLine;
1307
1405
  this.#setCursorCol(result.cursorCol);
1308
1406
  result.onApplied?.();
@@ -1436,95 +1534,175 @@ export class Editor implements Component, Focusable {
1436
1534
  return this.#wrappedLineCache.length;
1437
1535
  }
1438
1536
 
1439
- #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);
1440
1575
  const layoutLines: LayoutLine[] = [];
1441
1576
 
1442
- if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
1443
- // Empty editor — keep the wrap cache bounded by document size like
1444
- // the non-empty path below (stale entries from a previously large
1445
- // buffer must not be retained).
1446
- this.#wrappedLineCache.length = this.#state.lines.length;
1447
- layoutLines.push({
1448
- text: "",
1449
- hasCursor: true,
1450
- cursorPos: 0,
1451
- });
1577
+ if (wrappedLine.width <= contentWidth) {
1578
+ layoutLines.push(
1579
+ this.#layoutLine(line, lineIndex, isCurrentLine, isCurrentLine ? this.#state.cursorCol : undefined),
1580
+ );
1452
1581
  return layoutLines;
1453
1582
  }
1454
1583
 
1455
- // Process each logical line
1456
- for (let i = 0; i < this.#state.lines.length; i++) {
1457
- const line = this.#state.lines[i] || "";
1458
- const isCurrentLine = i === this.#state.cursorLine;
1459
- const wrappedLine = this.#getWrappedLine(i, contentWidth);
1460
-
1461
- if (wrappedLine.width <= contentWidth) {
1462
- // Line fits in one layout line
1463
- if (isCurrentLine) {
1464
- layoutLines.push({
1465
- text: line,
1466
- hasCursor: true,
1467
- cursorPos: this.#state.cursorCol,
1468
- });
1469
- } else {
1470
- layoutLines.push({
1471
- text: line,
1472
- hasCursor: false,
1473
- });
1474
- }
1475
- } else {
1476
- // Line needs wrapping - use word-aware wrapping
1477
- const chunks = wrappedLine.chunks;
1478
-
1479
- for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1480
- const chunk = chunks[chunkIndex];
1481
- if (!chunk) continue;
1482
-
1483
- const cursorPos = this.#state.cursorCol;
1484
- const isLastChunk = chunkIndex === chunks.length - 1;
1584
+ const chunks = wrappedLine.chunks;
1585
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1586
+ const chunk = chunks[chunkIndex];
1587
+ if (!chunk) continue;
1485
1588
 
1486
- // Determine if cursor is in this chunk
1487
- // For word-wrapped chunks, we need to handle the case where
1488
- // cursor might be in trimmed whitespace at end of chunk
1489
- let hasCursorInChunk = false;
1490
- let adjustedCursorPos = 0;
1589
+ const cursorPos = this.#state.cursorCol;
1590
+ const isLastChunk = chunkIndex === chunks.length - 1;
1591
+ let hasCursorInChunk = false;
1592
+ let adjustedCursorPos = 0;
1491
1593
 
1492
- if (isCurrentLine) {
1493
- if (isLastChunk) {
1494
- // Last chunk: cursor belongs here if >= startIndex
1495
- hasCursorInChunk = cursorPos >= chunk.startIndex;
1496
- adjustedCursorPos = cursorPos - chunk.startIndex;
1497
- } else {
1498
- // Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
1499
- // But we need to handle the visual position in the trimmed text
1500
- hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
1501
- if (hasCursorInChunk) {
1502
- adjustedCursorPos = cursorPos - chunk.startIndex;
1503
- // Clamp to text length (in case cursor was in trimmed whitespace)
1504
- if (adjustedCursorPos > chunk.text.length) {
1505
- adjustedCursorPos = chunk.text.length;
1506
- }
1507
- }
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;
1508
1604
  }
1509
1605
  }
1606
+ }
1607
+ }
1510
1608
 
1511
- if (hasCursorInChunk) {
1512
- layoutLines.push({
1513
- text: chunk.text,
1514
- hasCursor: true,
1515
- cursorPos: adjustedCursorPos,
1516
- });
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);
1517
1618
  } else {
1518
- layoutLines.push({
1519
- text: chunk.text,
1520
- hasCursor: false,
1521
- });
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;
1522
1627
  }
1523
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);
1524
1694
  }
1525
1695
  }
1526
1696
 
1527
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
+ };
1528
1706
  return layoutLines;
1529
1707
  }
1530
1708
 
@@ -1596,6 +1774,7 @@ export class Editor implements Component, Focusable {
1596
1774
  this.#resetKillSequence();
1597
1775
  this.#preferredVisualCol = null;
1598
1776
  this.#state.lines[this.#state.cursorLine] = beforeTransient + afterTransient;
1777
+ this.#bumpDocumentVersion();
1599
1778
  this.#setCursorCol(transientStartCol);
1600
1779
 
1601
1780
  while (true) {
@@ -1644,18 +1823,7 @@ export class Editor implements Component, Focusable {
1644
1823
  /** Insert text at the current cursor position */
1645
1824
  insertText(text: string): void {
1646
1825
  this.#exitHistoryForEditing();
1647
- this.#resetKillSequence();
1648
- this.#recordUndoState();
1649
-
1650
- const line = this.#state.lines[this.#state.cursorLine] || "";
1651
- const inserted = insertTextNfcAt(line, this.#state.cursorCol, text);
1652
-
1653
- this.#state.lines[this.#state.cursorLine] = inserted.line;
1654
- this.#setCursorCol(inserted.cursorCol);
1655
-
1656
- if (this.onChange) {
1657
- this.onChange(this.getText());
1658
- }
1826
+ this.#insertTextAtCursor(text);
1659
1827
  }
1660
1828
 
1661
1829
  // All the editor methods from before...
@@ -1699,8 +1867,8 @@ export class Editor implements Component, Focusable {
1699
1867
 
1700
1868
  // Check if we should trigger or update autocomplete
1701
1869
  if (!this.#autocompleteState) {
1702
- // Auto-trigger for "/" at the start of a line (slash commands)
1703
- if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
1870
+ // Auto-trigger for slash command tokens.
1871
+ if (char === "/" && (this.#isAtStartOfSubmittedMessage() || this.#isInSlashTokenContext())) {
1704
1872
  this.#tryTriggerAutocomplete();
1705
1873
  }
1706
1874
  // Auto-trigger for "@" file reference (fuzzy search)
@@ -1721,8 +1889,8 @@ export class Editor implements Component, Focusable {
1721
1889
  else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
1722
1890
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1723
1891
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1724
- // Check if we're in a slash command (with or without space for arguments)
1725
- if (this.#isInSubmittedSlashCommandContext()) {
1892
+ // Check if we're in a slash command or inline slash token
1893
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
1726
1894
  this.#tryTriggerAutocomplete();
1727
1895
  }
1728
1896
  // Check if we're in an @ file reference context
@@ -1747,6 +1915,11 @@ export class Editor implements Component, Focusable {
1747
1915
  this.#historyIndex = -1; // Exit history browsing mode
1748
1916
  this.#resetKillSequence();
1749
1917
  this.#recordUndoState();
1918
+ const hadAutocomplete = this.#autocompleteState !== null;
1919
+ this.#cancelAutocomplete();
1920
+ if (hadAutocomplete) {
1921
+ this.onAutocompleteUpdate?.();
1922
+ }
1750
1923
 
1751
1924
  this.#withUndoSuspended(() => {
1752
1925
  // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
@@ -1774,21 +1947,12 @@ export class Editor implements Component, Focusable {
1774
1947
  const tabExpandedText = cleanText.replace(/\t/g, " ");
1775
1948
 
1776
1949
  // Filter out non-printable characters except newlines
1777
- let filteredText = tabExpandedText
1950
+ const filteredText = tabExpandedText
1778
1951
  .split("")
1779
1952
  .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1780
1953
  .join("");
1781
1954
 
1782
- // If pasting a file path (starts with /, ~, or .) and the character before
1783
- // the cursor is a word character, prepend a space for better readability
1784
- if (/^[/~.]/.test(filteredText)) {
1785
- const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1786
- const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
1787
- if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
1788
- filteredText = ` ${filteredText}`;
1789
- }
1790
- }
1791
-
1955
+ if (filteredText.length === 0) return;
1792
1956
  // Split into lines
1793
1957
  const pastedLines = filteredText.split("\n");
1794
1958
 
@@ -1810,15 +1974,10 @@ export class Editor implements Component, Focusable {
1810
1974
  return;
1811
1975
  }
1812
1976
 
1813
- if (pastedLines.length === 1) {
1814
- // Single line - insert character by character to trigger autocomplete
1815
- for (const char of filteredText) {
1816
- this.#insertCharacter(char);
1817
- }
1818
- return;
1819
- }
1820
-
1821
- // 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.
1822
1981
  this.#insertTextAtCursor(filteredText);
1823
1982
  });
1824
1983
  }
@@ -1863,6 +2022,7 @@ export class Editor implements Component, Focusable {
1863
2022
  const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
1864
2023
 
1865
2024
  this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
2025
+ this.#bumpDocumentVersion();
1866
2026
  this.#pastes.clear();
1867
2027
  this.#pasteCounter = 0;
1868
2028
  this.#historyIndex = -1;
@@ -1917,8 +2077,8 @@ export class Editor implements Component, Focusable {
1917
2077
  // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
1918
2078
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1919
2079
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1920
- // Slash command context
1921
- if (this.#isInSubmittedSlashCommandContext()) {
2080
+ // Slash command or inline slash token context
2081
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
1922
2082
  this.#tryTriggerAutocomplete();
1923
2083
  }
1924
2084
  // @ file reference context
@@ -2054,6 +2214,7 @@ export class Editor implements Component, Focusable {
2054
2214
  #recordUndoState(): void {
2055
2215
  if (this.#suspendUndo) return;
2056
2216
  this.#undoStack.push(structuredClone(this.#state));
2217
+ this.#bumpDocumentVersion();
2057
2218
  }
2058
2219
 
2059
2220
  #applyUndo(): void {
@@ -2064,6 +2225,7 @@ export class Editor implements Component, Focusable {
2064
2225
  this.#resetKillSequence();
2065
2226
  this.#preferredVisualCol = null;
2066
2227
  Object.assign(this.#state, snapshot);
2228
+ this.#bumpDocumentVersion();
2067
2229
 
2068
2230
  if (this.onChange) {
2069
2231
  this.onChange(this.getText());
@@ -2074,7 +2236,7 @@ export class Editor implements Component, Focusable {
2074
2236
  } else {
2075
2237
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2076
2238
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2077
- if (this.#isInSubmittedSlashCommandContext()) {
2239
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
2078
2240
  this.#tryTriggerAutocomplete();
2079
2241
  } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
2080
2242
  this.#tryTriggerAutocomplete();
@@ -2388,8 +2550,8 @@ export class Editor implements Component, Focusable {
2388
2550
  } else {
2389
2551
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2390
2552
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2391
- // Slash command context
2392
- if (this.#isInSubmittedSlashCommandContext()) {
2553
+ // Slash command or inline slash token context
2554
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
2393
2555
  this.#tryTriggerAutocomplete();
2394
2556
  }
2395
2557
  // @ file reference context
@@ -2569,6 +2731,12 @@ export class Editor implements Component, Focusable {
2569
2731
  : this.#state.cursorCol - 1
2570
2732
  : undefined;
2571
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
+
2572
2740
  const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
2573
2741
 
2574
2742
  if (idx !== -1) {
@@ -2618,6 +2786,16 @@ export class Editor implements Component, Focusable {
2618
2786
  return this.#hasOnlyWhitespaceBeforeCursorLine() && beforeCursor.trimStart().startsWith("/");
2619
2787
  }
2620
2788
 
2789
+ #getSlashTokenBeforeCursor(): string | null {
2790
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2791
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2792
+ return extractSlashCommandTokenPrefix(beforeCursor);
2793
+ }
2794
+
2795
+ #isInSlashTokenContext(): boolean {
2796
+ return this.#getSlashTokenBeforeCursor() !== null;
2797
+ }
2798
+
2621
2799
  #isSlashCommandNameAutocompleteSelection(): boolean {
2622
2800
  if (this.#autocompleteState !== "regular") {
2623
2801
  return false;
@@ -2699,7 +2877,10 @@ export class Editor implements Component, Focusable {
2699
2877
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2700
2878
 
2701
2879
  // Check if we're in a slash command context
2702
- if (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) {
2880
+ if (
2881
+ (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) ||
2882
+ this.#isInSlashTokenContext()
2883
+ ) {
2703
2884
  this.#handleSlashCommandCompletion();
2704
2885
  } else {
2705
2886
  this.#forceFileAutocomplete(true);
@@ -2748,6 +2929,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2748
2929
  );
2749
2930
 
2750
2931
  this.#state.lines = result.lines;
2932
+ this.#bumpDocumentVersion();
2751
2933
  this.#state.cursorLine = result.cursorLine;
2752
2934
  this.#setCursorCol(result.cursorCol);
2753
2935