@markusylisiurunen/tau 0.2.76 → 0.2.77

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,7 @@
1
- import { getEditorKeybindings, matchesKey, SelectList, visibleWidth, } from "@mariozechner/pi-tui";
1
+ import { decodeKittyPrintable, getEditorKeybindings, matchesKey, SelectList, truncateToWidth, visibleWidth, } from "@mariozechner/pi-tui";
2
2
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
+ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
4
+ const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
3
5
  const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
4
6
  const CSI_PATTERN = "\\x1b\\[[0-9;]*[A-Za-z]";
5
7
  const OSC_PATTERN = "\\x1b\\][^\\x07]*(?:\\x07|\\x1b\\\\)";
@@ -13,6 +15,45 @@ function isWhitespaceChar(char) {
13
15
  function isPunctuationChar(char) {
14
16
  return PUNCTUATION_REGEX.test(char);
15
17
  }
18
+ function isPasteMarker(segment) {
19
+ return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
20
+ }
21
+ function segmentWithMarkers(text, validIds) {
22
+ if (validIds.size === 0 || !text.includes("[paste #")) {
23
+ return segmenter.segment(text);
24
+ }
25
+ const markers = [];
26
+ for (const match of text.matchAll(PASTE_MARKER_REGEX)) {
27
+ const id = Number.parseInt(match[1] ?? "", 10);
28
+ if (!validIds.has(id))
29
+ continue;
30
+ markers.push({ start: match.index ?? 0, end: (match.index ?? 0) + match[0].length });
31
+ }
32
+ if (markers.length === 0) {
33
+ return segmenter.segment(text);
34
+ }
35
+ const baseSegments = segmenter.segment(text);
36
+ const result = [];
37
+ let markerIndex = 0;
38
+ for (const seg of baseSegments) {
39
+ while (markerIndex < markers.length && (markers[markerIndex]?.end ?? 0) <= seg.index) {
40
+ markerIndex++;
41
+ }
42
+ const marker = markerIndex < markers.length ? markers[markerIndex] : null;
43
+ if (marker && seg.index >= marker.start && seg.index < marker.end) {
44
+ if (seg.index === marker.start) {
45
+ result.push({
46
+ segment: text.slice(marker.start, marker.end),
47
+ index: marker.start,
48
+ input: text,
49
+ });
50
+ }
51
+ continue;
52
+ }
53
+ result.push(seg);
54
+ }
55
+ return result;
56
+ }
16
57
  function stripAnsiSequences(text) {
17
58
  if (!text.includes("\x1b"))
18
59
  return text;
@@ -25,6 +66,11 @@ function stripAnsiSequences(text) {
25
66
  function sanitizeInputText(text) {
26
67
  return text ? stripAnsiSequences(text) : text;
27
68
  }
69
+ const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
70
+ minPrimaryColumnWidth: 12,
71
+ maxPrimaryColumnWidth: 40,
72
+ truncatePrimary: ({ text, maxWidth }) => truncateToWidth(text, maxWidth, "…"),
73
+ };
28
74
  /**
29
75
  * Split a line into word-wrapped chunks.
30
76
  * Wraps at word boundaries when possible, falling back to character-level
@@ -34,7 +80,7 @@ function sanitizeInputText(text) {
34
80
  * @param maxWidth - Maximum visible width per chunk
35
81
  * @returns Array of chunks with text and position information
36
82
  */
37
- function wordWrapLine(line, maxWidth) {
83
+ function wordWrapLine(line, maxWidth, preSegmented) {
38
84
  if (!line || maxWidth <= 0) {
39
85
  return [{ text: "", startIndex: 0, endIndex: 0 }];
40
86
  }
@@ -43,141 +89,69 @@ function wordWrapLine(line, maxWidth) {
43
89
  return [{ text: line, startIndex: 0, endIndex: line.length }];
44
90
  }
45
91
  const chunks = [];
46
- // Split into tokens (words and whitespace runs)
47
- const tokens = [];
48
- let currentToken = "";
49
- let tokenStart = 0;
50
- let inWhitespace = false;
51
- let charIndex = 0;
52
- for (const seg of segmenter.segment(line)) {
53
- const grapheme = seg.segment;
54
- const graphemeIsWhitespace = isWhitespaceChar(grapheme);
55
- if (currentToken === "") {
56
- inWhitespace = graphemeIsWhitespace;
57
- tokenStart = charIndex;
58
- }
59
- else if (graphemeIsWhitespace !== inWhitespace) {
60
- // Token type changed - save current token
61
- tokens.push({
62
- text: currentToken,
63
- startIndex: tokenStart,
64
- endIndex: charIndex,
65
- isWhitespace: inWhitespace,
66
- });
67
- currentToken = "";
68
- tokenStart = charIndex;
69
- inWhitespace = graphemeIsWhitespace;
70
- }
71
- currentToken += grapheme;
72
- charIndex += grapheme.length;
73
- }
74
- // Push final token
75
- if (currentToken) {
76
- tokens.push({
77
- text: currentToken,
78
- startIndex: tokenStart,
79
- endIndex: charIndex,
80
- isWhitespace: inWhitespace,
81
- });
82
- }
83
- // Build chunks using word wrapping
84
- let currentChunk = "";
92
+ const segments = preSegmented ?? [...segmenter.segment(line)];
85
93
  let currentWidth = 0;
86
- let chunkStartIndex = 0;
87
- let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)
88
- for (const token of tokens) {
89
- const tokenWidth = visibleWidth(token.text);
90
- // Skip leading whitespace at line start
91
- if (atLineStart && token.isWhitespace) {
92
- chunkStartIndex = token.endIndex;
94
+ let chunkStart = 0;
95
+ let wrapOppIndex = -1;
96
+ let wrapOppWidth = 0;
97
+ for (let i = 0; i < segments.length; i++) {
98
+ const seg = segments[i];
99
+ if (!seg)
93
100
  continue;
94
- }
95
- atLineStart = false;
96
- // If this single token is wider than maxWidth, we need to break it
97
- if (tokenWidth > maxWidth) {
98
- // First, push any accumulated chunk
99
- if (currentChunk) {
101
+ const grapheme = seg.segment;
102
+ const graphemeWidth = visibleWidth(grapheme);
103
+ const charIndex = seg.index;
104
+ const isWhitespace = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
105
+ if (currentWidth + graphemeWidth > maxWidth) {
106
+ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + graphemeWidth <= maxWidth) {
100
107
  chunks.push({
101
- text: currentChunk,
102
- startIndex: chunkStartIndex,
103
- endIndex: token.startIndex,
108
+ text: line.slice(chunkStart, wrapOppIndex),
109
+ startIndex: chunkStart,
110
+ endIndex: wrapOppIndex,
104
111
  });
105
- currentChunk = "";
106
- currentWidth = 0;
107
- chunkStartIndex = token.startIndex;
108
- }
109
- // Break the long token by grapheme
110
- let tokenChunk = "";
111
- let tokenChunkWidth = 0;
112
- let tokenChunkStart = token.startIndex;
113
- let tokenCharIndex = token.startIndex;
114
- for (const seg of segmenter.segment(token.text)) {
115
- const grapheme = seg.segment;
116
- const graphemeWidth = visibleWidth(grapheme);
117
- if (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) {
118
- chunks.push({
119
- text: tokenChunk,
120
- startIndex: tokenChunkStart,
121
- endIndex: tokenCharIndex,
122
- });
123
- tokenChunk = grapheme;
124
- tokenChunkWidth = graphemeWidth;
125
- tokenChunkStart = tokenCharIndex;
126
- }
127
- else {
128
- tokenChunk += grapheme;
129
- tokenChunkWidth += graphemeWidth;
130
- }
131
- tokenCharIndex += grapheme.length;
112
+ chunkStart = wrapOppIndex;
113
+ currentWidth -= wrapOppWidth;
132
114
  }
133
- // Keep remainder as start of next chunk
134
- if (tokenChunk) {
135
- currentChunk = tokenChunk;
136
- currentWidth = tokenChunkWidth;
137
- chunkStartIndex = tokenChunkStart;
138
- }
139
- continue;
140
- }
141
- // Check if adding this token would exceed width
142
- if (currentWidth + tokenWidth > maxWidth) {
143
- // Push current chunk (trimming trailing whitespace for display)
144
- const trimmedChunk = currentChunk.trimEnd();
145
- if (trimmedChunk || chunks.length === 0) {
115
+ else if (chunkStart < charIndex) {
146
116
  chunks.push({
147
- text: trimmedChunk,
148
- startIndex: chunkStartIndex,
149
- endIndex: chunkStartIndex + currentChunk.length,
117
+ text: line.slice(chunkStart, charIndex),
118
+ startIndex: chunkStart,
119
+ endIndex: charIndex,
150
120
  });
151
- }
152
- // Start new line - skip leading whitespace
153
- atLineStart = true;
154
- if (token.isWhitespace) {
155
- currentChunk = "";
121
+ chunkStart = charIndex;
156
122
  currentWidth = 0;
157
- chunkStartIndex = token.endIndex;
158
123
  }
159
- else {
160
- currentChunk = token.text;
161
- currentWidth = tokenWidth;
162
- chunkStartIndex = token.startIndex;
163
- atLineStart = false;
124
+ wrapOppIndex = -1;
125
+ }
126
+ if (graphemeWidth > maxWidth) {
127
+ const subChunks = wordWrapLine(grapheme, maxWidth);
128
+ for (let j = 0; j < subChunks.length - 1; j++) {
129
+ const subChunk = subChunks[j];
130
+ if (!subChunk)
131
+ continue;
132
+ chunks.push({
133
+ text: subChunk.text,
134
+ startIndex: charIndex + subChunk.startIndex,
135
+ endIndex: charIndex + subChunk.endIndex,
136
+ });
137
+ }
138
+ const lastSubChunk = subChunks[subChunks.length - 1];
139
+ if (lastSubChunk) {
140
+ chunkStart = charIndex + lastSubChunk.startIndex;
141
+ currentWidth = visibleWidth(lastSubChunk.text);
164
142
  }
143
+ wrapOppIndex = -1;
144
+ continue;
165
145
  }
166
- else {
167
- // Add token to current chunk
168
- currentChunk += token.text;
169
- currentWidth += tokenWidth;
146
+ currentWidth += graphemeWidth;
147
+ const next = segments[i + 1];
148
+ if (isWhitespace && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
149
+ wrapOppIndex = next.index;
150
+ wrapOppWidth = currentWidth;
170
151
  }
171
152
  }
172
- // Push final chunk
173
- if (currentChunk) {
174
- chunks.push({
175
- text: currentChunk,
176
- startIndex: chunkStartIndex,
177
- endIndex: line.length,
178
- });
179
- }
180
- return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }];
153
+ chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
154
+ return chunks;
181
155
  }
182
156
  export class Editor {
183
157
  state = {
@@ -193,7 +167,7 @@ export class Editor {
193
167
  // Autocomplete support
194
168
  autocompleteProvider;
195
169
  autocompleteList;
196
- isAutocompleting = false;
170
+ autocompleteState = null;
197
171
  autocompletePrefix = "";
198
172
  // Paste tracking for large pastes
199
173
  pastes = new Map();
@@ -211,6 +185,15 @@ export class Editor {
211
185
  this.theme = theme;
212
186
  this.borderColor = theme.borderColor;
213
187
  }
188
+ validPasteIds() {
189
+ return new Set(this.pastes.keys());
190
+ }
191
+ segment(text) {
192
+ return segmentWithMarkers(text, this.validPasteIds());
193
+ }
194
+ normalizeText(text) {
195
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
196
+ }
214
197
  /** Wraps text in cursor styling (inverse video). Override in subclasses for theme-aware cursor. */
215
198
  cursorStyle(text) {
216
199
  return `\x1b[7m${text}\x1b[0m`;
@@ -300,11 +283,11 @@ export class Editor {
300
283
  const after = displayText.slice(layoutLine.cursorPos);
301
284
  if (after.length > 0) {
302
285
  // Cursor is on a character (grapheme) - replace it with highlighted version
303
- // Get the first grapheme from 'after'
304
- const afterGraphemes = [...segmenter.segment(after)];
305
- const firstGrapheme = afterGraphemes[0]?.segment || "";
306
- const restAfter = after.slice(firstGrapheme.length);
307
- displayText = before + this.cursorStyle(firstGrapheme) + restAfter;
286
+ // Get the first segment from 'after'
287
+ const afterSegments = [...this.segment(after)];
288
+ const firstSegment = afterSegments[0]?.segment || "";
289
+ const restAfter = after.slice(firstSegment.length);
290
+ displayText = before + this.cursorStyle(firstSegment) + restAfter;
308
291
  // lineVisibleWidth stays the same - we're replacing, not adding
309
292
  }
310
293
  else {
@@ -316,17 +299,17 @@ export class Editor {
316
299
  lineVisibleWidth = lineVisibleWidth + 1;
317
300
  }
318
301
  else {
319
- // Line is at full width - use reverse video on last grapheme if possible
302
+ // Line is at full width - use reverse video on last segment if possible
320
303
  // or just show cursor at the end without adding space
321
- const beforeGraphemes = [...segmenter.segment(before)];
322
- if (beforeGraphemes.length > 0) {
323
- const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || "";
324
- // Rebuild 'before' without the last grapheme
325
- const beforeWithoutLast = beforeGraphemes
304
+ const beforeSegments = [...this.segment(before)];
305
+ if (beforeSegments.length > 0) {
306
+ const lastSegment = beforeSegments[beforeSegments.length - 1]?.segment || "";
307
+ // Rebuild 'before' without the last segment
308
+ const beforeWithoutLast = beforeSegments
326
309
  .slice(0, -1)
327
310
  .map((g) => g.segment)
328
311
  .join("");
329
- displayText = beforeWithoutLast + this.cursorStyle(lastGrapheme);
312
+ displayText = beforeWithoutLast + this.cursorStyle(lastSegment);
330
313
  }
331
314
  // lineVisibleWidth stays the same
332
315
  }
@@ -340,7 +323,7 @@ export class Editor {
340
323
  // Render bottom border
341
324
  result.push(horizontal.repeat(width));
342
325
  // Add autocomplete list if active
343
- if (this.isAutocompleting && this.autocompleteList) {
326
+ if (this.autocompleteState && this.autocompleteList) {
344
327
  const autocompleteResult = this.autocompleteList.render(width);
345
328
  result.push(...autocompleteResult);
346
329
  }
@@ -381,7 +364,7 @@ export class Editor {
381
364
  return;
382
365
  }
383
366
  // Handle autocomplete mode
384
- if (this.isAutocompleting && this.autocompleteList) {
367
+ if (this.autocompleteState && this.autocompleteList) {
385
368
  if (kb.matches(data, "selectCancel")) {
386
369
  this.cancelAutocomplete();
387
370
  return;
@@ -434,7 +417,7 @@ export class Editor {
434
417
  }
435
418
  }
436
419
  // Tab - trigger completion
437
- if (kb.matches(data, "tab") && !this.isAutocompleting) {
420
+ if (kb.matches(data, "tab") && !this.autocompleteState) {
438
421
  this.handleTabCompletion();
439
422
  return;
440
423
  }
@@ -538,6 +521,11 @@ export class Editor {
538
521
  this.insertCharacter(" ");
539
522
  return;
540
523
  }
524
+ const kittyPrintable = decodeKittyPrintable(data);
525
+ if (kittyPrintable !== undefined) {
526
+ this.insertCharacter(kittyPrintable);
527
+ return;
528
+ }
541
529
  // Regular characters
542
530
  if (data.charCodeAt(0) >= 32) {
543
531
  this.insertCharacter(data);
@@ -578,7 +566,7 @@ export class Editor {
578
566
  }
579
567
  else {
580
568
  // Line needs wrapping - use word-aware wrapping
581
- const chunks = wordWrapLine(line, contentWidth);
569
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
582
570
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
583
571
  const chunk = chunks[chunkIndex];
584
572
  if (!chunk)
@@ -660,8 +648,40 @@ export class Editor {
660
648
  * Used for programmatic insertion (e.g., clipboard image markers).
661
649
  */
662
650
  insertTextAtCursor(text) {
663
- for (const char of text) {
664
- this.insertCharacter(char);
651
+ this.insertTextAtCursorInternal(text);
652
+ }
653
+ insertTextAtCursorInternal(text) {
654
+ if (!text)
655
+ return;
656
+ const sanitized = sanitizeInputText(text);
657
+ if (!sanitized)
658
+ return;
659
+ this.historyIndex = -1;
660
+ const normalized = this.normalizeText(sanitized);
661
+ const insertedLines = normalized.split("\n");
662
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
663
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
664
+ const afterCursor = currentLine.slice(this.state.cursorCol);
665
+ if (insertedLines.length === 1) {
666
+ this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor;
667
+ this.state.cursorCol += normalized.length;
668
+ }
669
+ else {
670
+ this.state.lines = [
671
+ ...this.state.lines.slice(0, this.state.cursorLine),
672
+ beforeCursor + (insertedLines[0] || ""),
673
+ ...insertedLines.slice(1, -1),
674
+ (insertedLines[insertedLines.length - 1] || "") + afterCursor,
675
+ ...this.state.lines.slice(this.state.cursorLine + 1),
676
+ ];
677
+ this.state.cursorLine += insertedLines.length - 1;
678
+ this.state.cursorCol = (insertedLines[insertedLines.length - 1] || "").length;
679
+ }
680
+ if (this.onChange) {
681
+ this.onChange(this.getText());
682
+ }
683
+ if (this.autocompleteState) {
684
+ this.updateAutocomplete();
665
685
  }
666
686
  }
667
687
  // All the editor methods from before...
@@ -679,7 +699,7 @@ export class Editor {
679
699
  this.onChange(this.getText());
680
700
  }
681
701
  // Check if we should trigger or update autocomplete
682
- if (!this.isAutocompleting) {
702
+ if (!this.autocompleteState) {
683
703
  // Auto-trigger for "/" at the start of a line (slash commands)
684
704
  if (sanitized === "/" && this.isAtStartOfMessage()) {
685
705
  this.tryTriggerAutocomplete();
@@ -698,11 +718,9 @@ export class Editor {
698
718
  else if (/[a-zA-Z0-9.\-_]/.test(sanitized)) {
699
719
  const currentLine = this.state.lines[this.state.cursorLine] || "";
700
720
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
701
- // Check if we're in a slash command (with or without space for arguments)
702
- if (textBeforeCursor.trimStart().startsWith("/")) {
721
+ if (this.isInSlashCommandContext(textBeforeCursor)) {
703
722
  this.tryTriggerAutocomplete();
704
723
  }
705
- // Check if we're in an @ mention context
706
724
  else if (this.isMentionAutocompleteContext(textBeforeCursor)) {
707
725
  this.tryTriggerAutocomplete();
708
726
  }
@@ -716,11 +734,9 @@ export class Editor {
716
734
  this.historyIndex = -1; // Exit history browsing mode
717
735
  // Clean the pasted text
718
736
  const sanitizedText = sanitizeInputText(pastedText);
719
- const cleanText = sanitizedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
720
- // Convert tabs to spaces (4 spaces per tab)
721
- const tabExpandedText = cleanText.replace(/\t/g, " ");
737
+ const cleanText = this.normalizeText(sanitizedText);
722
738
  // Filter out non-printable characters except newlines
723
- let filteredText = tabExpandedText
739
+ let filteredText = cleanText
724
740
  .split("")
725
741
  .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
726
742
  .join("");
@@ -746,50 +762,10 @@ export class Editor {
746
762
  const marker = pastedLines.length > 32
747
763
  ? `[paste #${pasteId} +${pastedLines.length} lines]`
748
764
  : `[paste #${pasteId} ${totalChars} chars]`;
749
- for (const char of marker) {
750
- this.insertCharacter(char);
751
- }
752
- return;
753
- }
754
- if (pastedLines.length === 1) {
755
- // Single line - just insert each character
756
- const text = pastedLines[0] || "";
757
- for (const char of text) {
758
- this.insertCharacter(char);
759
- }
765
+ this.insertTextAtCursorInternal(marker);
760
766
  return;
761
767
  }
762
- // Multi-line paste - be very careful with array manipulation
763
- const currentLine = this.state.lines[this.state.cursorLine] || "";
764
- const beforeCursor = currentLine.slice(0, this.state.cursorCol);
765
- const afterCursor = currentLine.slice(this.state.cursorCol);
766
- // Build the new lines array step by step
767
- const newLines = [];
768
- // Add all lines before current line
769
- for (let i = 0; i < this.state.cursorLine; i++) {
770
- newLines.push(this.state.lines[i] || "");
771
- }
772
- // Add the first pasted line merged with before cursor text
773
- newLines.push(beforeCursor + (pastedLines[0] || ""));
774
- // Add all middle pasted lines
775
- for (let i = 1; i < pastedLines.length - 1; i++) {
776
- newLines.push(pastedLines[i] || "");
777
- }
778
- // Add the last pasted line with after cursor text
779
- newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
780
- // Add all lines after current line
781
- for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
782
- newLines.push(this.state.lines[i] || "");
783
- }
784
- // Replace the entire lines array
785
- this.state.lines = newLines;
786
- // Update cursor position to end of pasted content
787
- this.state.cursorLine += pastedLines.length - 1;
788
- this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
789
- // Notify of change
790
- if (this.onChange) {
791
- this.onChange(this.getText());
792
- }
768
+ this.insertTextAtCursorInternal(filteredText);
793
769
  }
794
770
  addNewLine() {
795
771
  this.historyIndex = -1; // Exit history browsing mode
@@ -813,7 +789,7 @@ export class Editor {
813
789
  const line = this.state.lines[this.state.cursorLine] || "";
814
790
  const beforeCursor = line.slice(0, this.state.cursorCol);
815
791
  // Find the last grapheme in the text before cursor
816
- const graphemes = [...segmenter.segment(beforeCursor)];
792
+ const graphemes = [...this.segment(beforeCursor)];
817
793
  const lastGrapheme = graphemes[graphemes.length - 1];
818
794
  const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
819
795
  const before = line.slice(0, this.state.cursorCol - graphemeLength);
@@ -834,18 +810,16 @@ export class Editor {
834
810
  this.onChange(this.getText());
835
811
  }
836
812
  // Update or re-trigger autocomplete after backspace
837
- if (this.isAutocompleting) {
813
+ if (this.autocompleteState) {
838
814
  this.updateAutocomplete();
839
815
  }
840
816
  else {
841
817
  // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
842
818
  const currentLine = this.state.lines[this.state.cursorLine] || "";
843
819
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
844
- // Slash command context
845
- if (textBeforeCursor.trimStart().startsWith("/")) {
820
+ if (this.isInSlashCommandContext(textBeforeCursor)) {
846
821
  this.tryTriggerAutocomplete();
847
822
  }
848
- // @ mention context
849
823
  else if (this.isMentionAutocompleteContext(textBeforeCursor)) {
850
824
  this.tryTriggerAutocomplete();
851
825
  }
@@ -928,7 +902,7 @@ export class Editor {
928
902
  // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
929
903
  const afterCursor = currentLine.slice(this.state.cursorCol);
930
904
  // Find the first grapheme at cursor
931
- const graphemes = [...segmenter.segment(afterCursor)];
905
+ const graphemes = [...this.segment(afterCursor)];
932
906
  const firstGrapheme = graphemes[0];
933
907
  const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
934
908
  const before = currentLine.slice(0, this.state.cursorCol);
@@ -945,17 +919,15 @@ export class Editor {
945
919
  this.onChange(this.getText());
946
920
  }
947
921
  // Update or re-trigger autocomplete after forward delete
948
- if (this.isAutocompleting) {
922
+ if (this.autocompleteState) {
949
923
  this.updateAutocomplete();
950
924
  }
951
925
  else {
952
926
  const currentLine = this.state.lines[this.state.cursorLine] || "";
953
927
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
954
- // Slash command context
955
- if (textBeforeCursor.trimStart().startsWith("/")) {
928
+ if (this.isInSlashCommandContext(textBeforeCursor)) {
956
929
  this.tryTriggerAutocomplete();
957
930
  }
958
- // @ mention context
959
931
  else if (this.isMentionAutocompleteContext(textBeforeCursor)) {
960
932
  this.tryTriggerAutocomplete();
961
933
  }
@@ -982,7 +954,7 @@ export class Editor {
982
954
  }
983
955
  else {
984
956
  // Line needs wrapping - use word-aware wrapping
985
- const chunks = wordWrapLine(line, width);
957
+ const chunks = wordWrapLine(line, width, [...this.segment(line)]);
986
958
  for (const chunk of chunks) {
987
959
  visualLines.push({
988
960
  logicalLine: i,
@@ -1016,6 +988,18 @@ export class Editor {
1016
988
  // Fallback: return last visual line
1017
989
  return visualLines.length - 1;
1018
990
  }
991
+ snapCursorToSegmentBoundary(logicalLine, movingUp) {
992
+ for (const seg of this.segment(logicalLine)) {
993
+ if (seg.index > this.state.cursorCol)
994
+ break;
995
+ if (seg.segment.length <= 1)
996
+ continue;
997
+ if (this.state.cursorCol < seg.index + seg.segment.length) {
998
+ this.state.cursorCol = movingUp ? seg.index : seg.index + seg.segment.length;
999
+ break;
1000
+ }
1001
+ }
1002
+ }
1019
1003
  moveCursor(deltaLine, deltaCol) {
1020
1004
  const width = this.lastWidth;
1021
1005
  if (deltaLine !== 0) {
@@ -1035,6 +1019,7 @@ export class Editor {
1035
1019
  const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length);
1036
1020
  const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1037
1021
  this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1022
+ this.snapCursorToSegmentBoundary(logicalLine, deltaLine < 0);
1038
1023
  }
1039
1024
  }
1040
1025
  }
@@ -1044,7 +1029,7 @@ export class Editor {
1044
1029
  // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1045
1030
  if (this.state.cursorCol < currentLine.length) {
1046
1031
  const afterCursor = currentLine.slice(this.state.cursorCol);
1047
- const graphemes = [...segmenter.segment(afterCursor)];
1032
+ const graphemes = [...this.segment(afterCursor)];
1048
1033
  const firstGrapheme = graphemes[0];
1049
1034
  this.state.cursorCol += firstGrapheme ? firstGrapheme.segment.length : 1;
1050
1035
  }
@@ -1058,7 +1043,7 @@ export class Editor {
1058
1043
  // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1059
1044
  if (this.state.cursorCol > 0) {
1060
1045
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1061
- const graphemes = [...segmenter.segment(beforeCursor)];
1046
+ const graphemes = [...this.segment(beforeCursor)];
1062
1047
  const lastGrapheme = graphemes[graphemes.length - 1];
1063
1048
  this.state.cursorCol -= lastGrapheme ? lastGrapheme.segment.length : 1;
1064
1049
  }
@@ -1083,19 +1068,24 @@ export class Editor {
1083
1068
  return;
1084
1069
  }
1085
1070
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1086
- const graphemes = [...segmenter.segment(textBeforeCursor)];
1071
+ const graphemes = [...this.segment(textBeforeCursor)];
1087
1072
  let newCol = this.state.cursorCol;
1088
1073
  // Skip trailing whitespace
1089
1074
  while (graphemes.length > 0 &&
1075
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
1090
1076
  isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1091
1077
  newCol -= graphemes.pop()?.segment.length || 0;
1092
1078
  }
1093
1079
  if (graphemes.length > 0) {
1094
1080
  const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
1095
- if (isPunctuationChar(lastGrapheme)) {
1081
+ if (isPasteMarker(lastGrapheme)) {
1082
+ newCol -= graphemes.pop()?.segment.length || 0;
1083
+ }
1084
+ else if (isPunctuationChar(lastGrapheme)) {
1096
1085
  // Skip punctuation run
1097
1086
  while (graphemes.length > 0 &&
1098
- isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1087
+ isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1088
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1099
1089
  newCol -= graphemes.pop()?.segment.length || 0;
1100
1090
  }
1101
1091
  }
@@ -1103,7 +1093,8 @@ export class Editor {
1103
1093
  // Skip word run
1104
1094
  while (graphemes.length > 0 &&
1105
1095
  !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
1106
- !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1096
+ !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1097
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1107
1098
  newCol -= graphemes.pop()?.segment.length || 0;
1108
1099
  }
1109
1100
  }
@@ -1121,20 +1112,28 @@ export class Editor {
1121
1112
  return;
1122
1113
  }
1123
1114
  const textAfterCursor = currentLine.slice(this.state.cursorCol);
1124
- const segments = segmenter.segment(textAfterCursor);
1115
+ const segments = this.segment(textAfterCursor);
1125
1116
  const iterator = segments[Symbol.iterator]();
1126
1117
  let next = iterator.next();
1118
+ let newCol = this.state.cursorCol;
1127
1119
  // Skip leading whitespace
1128
- while (!next.done && isWhitespaceChar(next.value.segment)) {
1129
- this.state.cursorCol += next.value.segment.length;
1120
+ while (!next.done &&
1121
+ !isPasteMarker(next.value.segment) &&
1122
+ isWhitespaceChar(next.value.segment)) {
1123
+ newCol += next.value.segment.length;
1130
1124
  next = iterator.next();
1131
1125
  }
1132
1126
  if (!next.done) {
1133
1127
  const firstGrapheme = next.value.segment;
1134
- if (isPunctuationChar(firstGrapheme)) {
1128
+ if (isPasteMarker(firstGrapheme)) {
1129
+ newCol += firstGrapheme.length;
1130
+ }
1131
+ else if (isPunctuationChar(firstGrapheme)) {
1135
1132
  // Skip punctuation run
1136
- while (!next.done && isPunctuationChar(next.value.segment)) {
1137
- this.state.cursorCol += next.value.segment.length;
1133
+ while (!next.done &&
1134
+ isPunctuationChar(next.value.segment) &&
1135
+ !isPasteMarker(next.value.segment)) {
1136
+ newCol += next.value.segment.length;
1138
1137
  next = iterator.next();
1139
1138
  }
1140
1139
  }
@@ -1142,20 +1141,29 @@ export class Editor {
1142
1141
  // Skip word run
1143
1142
  while (!next.done &&
1144
1143
  !isWhitespaceChar(next.value.segment) &&
1145
- !isPunctuationChar(next.value.segment)) {
1146
- this.state.cursorCol += next.value.segment.length;
1144
+ !isPunctuationChar(next.value.segment) &&
1145
+ !isPasteMarker(next.value.segment)) {
1146
+ newCol += next.value.segment.length;
1147
1147
  next = iterator.next();
1148
1148
  }
1149
1149
  }
1150
1150
  }
1151
+ this.state.cursorCol = newCol;
1152
+ }
1153
+ isSlashMenuAllowed() {
1154
+ return this.state.lines.length === 1 && this.state.cursorLine === 0;
1151
1155
  }
1152
1156
  // Helper method to check if cursor is at start of message (for slash command detection)
1153
1157
  isAtStartOfMessage() {
1158
+ if (!this.isSlashMenuAllowed())
1159
+ return false;
1154
1160
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1155
1161
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1156
- // At start if line is empty, only contains whitespace, or is just "/"
1157
1162
  return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
1158
1163
  }
1164
+ isInSlashCommandContext(textBeforeCursor) {
1165
+ return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
1166
+ }
1159
1167
  isMentionAutocompleteContext(textBeforeCursor) {
1160
1168
  return /(?:^|[\s])@[^\s]*$/.test(textBeforeCursor);
1161
1169
  }
@@ -1164,11 +1172,44 @@ export class Editor {
1164
1172
  const beforeCursor = line.slice(0, cursorCol);
1165
1173
  return /(?:^|[\s])@@[a-z-]+:$/.test(beforeCursor);
1166
1174
  }
1175
+ getAutocompleteMatchPrefix(prefix) {
1176
+ if (prefix.startsWith("@@")) {
1177
+ const mentionMatch = prefix.match(/^@@[^:\s]+:(.*)$/);
1178
+ return mentionMatch ? (mentionMatch[1] ?? "") : prefix.slice(2);
1179
+ }
1180
+ if (prefix.startsWith("@") || prefix.startsWith("/")) {
1181
+ return prefix.slice(1);
1182
+ }
1183
+ return prefix;
1184
+ }
1185
+ getBestAutocompleteMatchIndex(items, prefix) {
1186
+ const matchPrefix = this.getAutocompleteMatchPrefix(prefix);
1187
+ if (!matchPrefix)
1188
+ return -1;
1189
+ let firstPrefixIndex = -1;
1190
+ for (let i = 0; i < items.length; i++) {
1191
+ const value = items[i]?.value;
1192
+ if (!value)
1193
+ continue;
1194
+ if (value === matchPrefix) {
1195
+ return i;
1196
+ }
1197
+ if (firstPrefixIndex === -1 && value.startsWith(matchPrefix)) {
1198
+ firstPrefixIndex = i;
1199
+ }
1200
+ }
1201
+ return firstPrefixIndex;
1202
+ }
1203
+ createAutocompleteList(prefix, items) {
1204
+ const layout = this.isInSlashCommandContext(prefix)
1205
+ ? SLASH_COMMAND_SELECT_LIST_LAYOUT
1206
+ : undefined;
1207
+ return new SelectList(items, 5, this.theme.selectList, layout);
1208
+ }
1167
1209
  // Autocomplete methods
1168
1210
  tryTriggerAutocomplete(explicitTab = false) {
1169
1211
  if (!this.autocompleteProvider)
1170
1212
  return;
1171
- // Check if we should trigger file completion on Tab
1172
1213
  if (explicitTab) {
1173
1214
  const provider = this.autocompleteProvider;
1174
1215
  const shouldTrigger = !provider.shouldTriggerFileCompletion ||
@@ -1180,8 +1221,12 @@ export class Editor {
1180
1221
  const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1181
1222
  if (suggestions && suggestions.items.length > 0) {
1182
1223
  this.autocompletePrefix = suggestions.prefix;
1183
- this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
1184
- this.isAutocompleting = true;
1224
+ this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
1225
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1226
+ if (bestMatchIndex >= 0) {
1227
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1228
+ }
1229
+ this.autocompleteState = "regular";
1185
1230
  }
1186
1231
  else {
1187
1232
  this.cancelAutocomplete();
@@ -1192,12 +1237,11 @@ export class Editor {
1192
1237
  return;
1193
1238
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1194
1239
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1195
- // Check if we're in a slash command context
1196
- if (beforeCursor.trimStart().startsWith("/") && !beforeCursor.trimStart().includes(" ")) {
1240
+ if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
1197
1241
  this.handleSlashCommandCompletion();
1198
1242
  }
1199
1243
  else {
1200
- this.forceFileAutocomplete();
1244
+ this.forceFileAutocomplete(true);
1201
1245
  }
1202
1246
  }
1203
1247
  handleSlashCommandCompletion() {
@@ -1208,10 +1252,9 @@ export class Editor {
1208
1252
  17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
1209
1253
  536643416/job/55932288317 havea look at .gi
1210
1254
  */
1211
- forceFileAutocomplete() {
1255
+ forceFileAutocomplete(explicitTab = false) {
1212
1256
  if (!this.autocompleteProvider)
1213
1257
  return;
1214
- // Check if provider supports force file suggestions via runtime check
1215
1258
  const provider = this.autocompleteProvider;
1216
1259
  if (typeof provider.getForceFileSuggestions !== "function") {
1217
1260
  this.tryTriggerAutocomplete(true);
@@ -1219,30 +1262,55 @@ export class Editor {
1219
1262
  }
1220
1263
  const suggestions = provider.getForceFileSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1221
1264
  if (suggestions && suggestions.items.length > 0) {
1265
+ if (explicitTab && suggestions.items.length === 1) {
1266
+ const item = suggestions.items[0];
1267
+ if (!item) {
1268
+ this.cancelAutocomplete();
1269
+ return;
1270
+ }
1271
+ const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, item, suggestions.prefix);
1272
+ this.state.lines = result.lines;
1273
+ this.state.cursorLine = result.cursorLine;
1274
+ this.state.cursorCol = result.cursorCol;
1275
+ if (this.onChange)
1276
+ this.onChange(this.getText());
1277
+ return;
1278
+ }
1222
1279
  this.autocompletePrefix = suggestions.prefix;
1223
- this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
1224
- this.isAutocompleting = true;
1280
+ this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
1281
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1282
+ if (bestMatchIndex >= 0) {
1283
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1284
+ }
1285
+ this.autocompleteState = "force";
1225
1286
  }
1226
1287
  else {
1227
1288
  this.cancelAutocomplete();
1228
1289
  }
1229
1290
  }
1230
1291
  cancelAutocomplete() {
1231
- this.isAutocompleting = false;
1292
+ this.autocompleteState = null;
1232
1293
  this.autocompleteList = undefined;
1233
1294
  this.autocompletePrefix = "";
1234
1295
  }
1235
1296
  isShowingAutocomplete() {
1236
- return this.isAutocompleting;
1297
+ return this.autocompleteState !== null;
1237
1298
  }
1238
1299
  updateAutocomplete() {
1239
- if (!this.isAutocompleting || !this.autocompleteProvider)
1300
+ if (!this.autocompleteState || !this.autocompleteProvider)
1301
+ return;
1302
+ if (this.autocompleteState === "force") {
1303
+ this.forceFileAutocomplete();
1240
1304
  return;
1305
+ }
1241
1306
  const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1242
1307
  if (suggestions && suggestions.items.length > 0) {
1243
1308
  this.autocompletePrefix = suggestions.prefix;
1244
- // Always create new SelectList to ensure update
1245
- this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
1309
+ this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
1310
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1311
+ if (bestMatchIndex >= 0) {
1312
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1313
+ }
1246
1314
  }
1247
1315
  else {
1248
1316
  this.cancelAutocomplete();