@oh-my-pi/pi-tui 1.337.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1342 @@
1
+ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
2
+ import {
3
+ isAltBackspace,
4
+ isAltEnter,
5
+ isAltLeft,
6
+ isAltRight,
7
+ isArrowDown,
8
+ isArrowLeft,
9
+ isArrowRight,
10
+ isArrowUp,
11
+ isBackspace,
12
+ isCtrlA,
13
+ isCtrlC,
14
+ isCtrlE,
15
+ isCtrlK,
16
+ isCtrlLeft,
17
+ isCtrlRight,
18
+ isCtrlU,
19
+ isCtrlW,
20
+ isDelete,
21
+ isEnd,
22
+ isEnter,
23
+ isEscape,
24
+ isHome,
25
+ isShiftEnter,
26
+ isTab,
27
+ } from "../keys.js";
28
+ import type { Component } from "../tui.js";
29
+ import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
30
+ import { SelectList, type SelectListTheme } from "./select-list.js";
31
+
32
+ const segmenter = getSegmenter();
33
+
34
+ interface EditorState {
35
+ lines: string[];
36
+ cursorLine: number;
37
+ cursorCol: number;
38
+ }
39
+
40
+ interface LayoutLine {
41
+ text: string;
42
+ hasCursor: boolean;
43
+ cursorPos?: number;
44
+ }
45
+
46
+ export interface EditorTheme {
47
+ borderColor: (str: string) => string;
48
+ selectList: SelectListTheme;
49
+ }
50
+
51
+ export class Editor implements Component {
52
+ private state: EditorState = {
53
+ lines: [""],
54
+ cursorLine: 0,
55
+ cursorCol: 0,
56
+ };
57
+
58
+ private theme: EditorTheme;
59
+
60
+ // Store last render width for cursor navigation
61
+ private lastWidth: number = 80;
62
+
63
+ // Border color (can be changed dynamically)
64
+ public borderColor: (str: string) => string;
65
+
66
+ // Autocomplete support
67
+ private autocompleteProvider?: AutocompleteProvider;
68
+ private autocompleteList?: SelectList;
69
+ private isAutocompleting: boolean = false;
70
+ private autocompletePrefix: string = "";
71
+
72
+ // Paste tracking for large pastes
73
+ private pastes: Map<number, string> = new Map();
74
+ private pasteCounter: number = 0;
75
+
76
+ // Bracketed paste mode buffering
77
+ private pasteBuffer: string = "";
78
+ private isInPaste: boolean = false;
79
+
80
+ // Prompt history for up/down navigation
81
+ private history: string[] = [];
82
+ private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
83
+
84
+ public onSubmit?: (text: string) => void;
85
+ public onChange?: (text: string) => void;
86
+ public disableSubmit: boolean = false;
87
+
88
+ constructor(theme: EditorTheme) {
89
+ this.theme = theme;
90
+ this.borderColor = theme.borderColor;
91
+ }
92
+
93
+ setAutocompleteProvider(provider: AutocompleteProvider): void {
94
+ this.autocompleteProvider = provider;
95
+ }
96
+
97
+ /**
98
+ * Add a prompt to history for up/down arrow navigation.
99
+ * Called after successful submission.
100
+ */
101
+ addToHistory(text: string): void {
102
+ const trimmed = text.trim();
103
+ if (!trimmed) return;
104
+ // Don't add consecutive duplicates
105
+ if (this.history.length > 0 && this.history[0] === trimmed) return;
106
+ this.history.unshift(trimmed);
107
+ // Limit history size
108
+ if (this.history.length > 100) {
109
+ this.history.pop();
110
+ }
111
+ }
112
+
113
+ private isEditorEmpty(): boolean {
114
+ return this.state.lines.length === 1 && this.state.lines[0] === "";
115
+ }
116
+
117
+ private isOnFirstVisualLine(): boolean {
118
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
119
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
120
+ return currentVisualLine === 0;
121
+ }
122
+
123
+ private isOnLastVisualLine(): boolean {
124
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
125
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
126
+ return currentVisualLine === visualLines.length - 1;
127
+ }
128
+
129
+ private navigateHistory(direction: 1 | -1): void {
130
+ if (this.history.length === 0) return;
131
+
132
+ const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
133
+ if (newIndex < -1 || newIndex >= this.history.length) return;
134
+
135
+ this.historyIndex = newIndex;
136
+
137
+ if (this.historyIndex === -1) {
138
+ // Returned to "current" state - clear editor
139
+ this.setTextInternal("");
140
+ } else {
141
+ this.setTextInternal(this.history[this.historyIndex] || "");
142
+ }
143
+ }
144
+
145
+ /** Internal setText that doesn't reset history state - used by navigateHistory */
146
+ private setTextInternal(text: string): void {
147
+ const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
148
+ this.state.lines = lines.length === 0 ? [""] : lines;
149
+ this.state.cursorLine = this.state.lines.length - 1;
150
+ this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
151
+
152
+ if (this.onChange) {
153
+ this.onChange(this.getText());
154
+ }
155
+ }
156
+
157
+ invalidate(): void {
158
+ // No cached state to invalidate currently
159
+ }
160
+
161
+ render(width: number): string[] {
162
+ // Store width for cursor navigation
163
+ this.lastWidth = width;
164
+
165
+ const horizontal = this.borderColor("─");
166
+
167
+ // Layout the text - use full width
168
+ const layoutLines = this.layoutText(width);
169
+
170
+ const result: string[] = [];
171
+
172
+ // Render top border
173
+ result.push(horizontal.repeat(width));
174
+
175
+ // Render each layout line
176
+ for (const layoutLine of layoutLines) {
177
+ let displayText = layoutLine.text;
178
+ let lineVisibleWidth = visibleWidth(layoutLine.text);
179
+
180
+ // Add cursor if this line has it
181
+ if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
182
+ const before = displayText.slice(0, layoutLine.cursorPos);
183
+ const after = displayText.slice(layoutLine.cursorPos);
184
+
185
+ if (after.length > 0) {
186
+ // Cursor is on a character (grapheme) - replace it with highlighted version
187
+ // Get the first grapheme from 'after'
188
+ const afterGraphemes = [...segmenter.segment(after)];
189
+ const firstGrapheme = afterGraphemes[0]?.segment || "";
190
+ const restAfter = after.slice(firstGrapheme.length);
191
+ const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
192
+ displayText = before + cursor + restAfter;
193
+ // lineVisibleWidth stays the same - we're replacing, not adding
194
+ } else {
195
+ // Cursor is at the end - check if we have room for the space
196
+ if (lineVisibleWidth < width) {
197
+ // We have room - add highlighted space
198
+ const cursor = "\x1b[7m \x1b[0m";
199
+ displayText = before + cursor;
200
+ // lineVisibleWidth increases by 1 - we're adding a space
201
+ lineVisibleWidth = lineVisibleWidth + 1;
202
+ } else {
203
+ // Line is at full width - use reverse video on last grapheme if possible
204
+ // or just show cursor at the end without adding space
205
+ const beforeGraphemes = [...segmenter.segment(before)];
206
+ if (beforeGraphemes.length > 0) {
207
+ const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || "";
208
+ const cursor = `\x1b[7m${lastGrapheme}\x1b[0m`;
209
+ // Rebuild 'before' without the last grapheme
210
+ const beforeWithoutLast = beforeGraphemes
211
+ .slice(0, -1)
212
+ .map((g) => g.segment)
213
+ .join("");
214
+ displayText = beforeWithoutLast + cursor;
215
+ }
216
+ // lineVisibleWidth stays the same
217
+ }
218
+ }
219
+ }
220
+
221
+ // Calculate padding based on actual visible width
222
+ const padding = " ".repeat(Math.max(0, width - lineVisibleWidth));
223
+
224
+ // Render the line (no side borders, just horizontal lines above and below)
225
+ result.push(displayText + padding);
226
+ }
227
+
228
+ // Render bottom border
229
+ result.push(horizontal.repeat(width));
230
+
231
+ // Add autocomplete list if active
232
+ if (this.isAutocompleting && this.autocompleteList) {
233
+ const autocompleteResult = this.autocompleteList.render(width);
234
+ result.push(...autocompleteResult);
235
+ }
236
+
237
+ return result;
238
+ }
239
+
240
+ handleInput(data: string): void {
241
+ // Handle bracketed paste mode
242
+ // Start of paste: \x1b[200~
243
+ // End of paste: \x1b[201~
244
+
245
+ // Check if we're starting a bracketed paste
246
+ if (data.includes("\x1b[200~")) {
247
+ this.isInPaste = true;
248
+ this.pasteBuffer = "";
249
+ // Remove the start marker and keep the rest
250
+ data = data.replace("\x1b[200~", "");
251
+ }
252
+
253
+ // If we're in a paste, buffer the data
254
+ if (this.isInPaste) {
255
+ // Append data to buffer first (end marker could be split across chunks)
256
+ this.pasteBuffer += data;
257
+
258
+ // Check if the accumulated buffer contains the end marker
259
+ const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
260
+ if (endIndex !== -1) {
261
+ // Extract content before the end marker
262
+ const pasteContent = this.pasteBuffer.substring(0, endIndex);
263
+
264
+ // Process the complete paste
265
+ this.handlePaste(pasteContent);
266
+
267
+ // Reset paste state
268
+ this.isInPaste = false;
269
+
270
+ // Process any remaining data after the end marker
271
+ const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
272
+ this.pasteBuffer = "";
273
+
274
+ if (remaining.length > 0) {
275
+ this.handleInput(remaining);
276
+ }
277
+ return;
278
+ } else {
279
+ // Still accumulating, wait for more data
280
+ return;
281
+ }
282
+ }
283
+
284
+ // Handle special key combinations first
285
+
286
+ // Ctrl+C - Exit (let parent handle this)
287
+ if (isCtrlC(data)) {
288
+ return;
289
+ }
290
+
291
+ // Handle autocomplete special keys first (but don't block other input)
292
+ if (this.isAutocompleting && this.autocompleteList) {
293
+ // Escape - cancel autocomplete
294
+ if (isEscape(data)) {
295
+ this.cancelAutocomplete();
296
+ return;
297
+ }
298
+ // Let the autocomplete list handle navigation and selection
299
+ else if (isArrowUp(data) || isArrowDown(data) || isEnter(data) || isTab(data)) {
300
+ // Only pass arrow keys to the list, not Enter/Tab (we handle those directly)
301
+ if (isArrowUp(data) || isArrowDown(data)) {
302
+ this.autocompleteList.handleInput(data);
303
+ return;
304
+ }
305
+
306
+ // If Tab was pressed, always apply the selection
307
+ if (isTab(data)) {
308
+ const selected = this.autocompleteList.getSelectedItem();
309
+ if (selected && this.autocompleteProvider) {
310
+ const result = this.autocompleteProvider.applyCompletion(
311
+ this.state.lines,
312
+ this.state.cursorLine,
313
+ this.state.cursorCol,
314
+ selected,
315
+ this.autocompletePrefix,
316
+ );
317
+
318
+ this.state.lines = result.lines;
319
+ this.state.cursorLine = result.cursorLine;
320
+ this.state.cursorCol = result.cursorCol;
321
+
322
+ this.cancelAutocomplete();
323
+
324
+ if (this.onChange) {
325
+ this.onChange(this.getText());
326
+ }
327
+ }
328
+ return;
329
+ }
330
+
331
+ // If Enter was pressed on a slash command, apply completion and submit
332
+ if (isEnter(data) && this.autocompletePrefix.startsWith("/")) {
333
+ const selected = this.autocompleteList.getSelectedItem();
334
+ if (selected && this.autocompleteProvider) {
335
+ const result = this.autocompleteProvider.applyCompletion(
336
+ this.state.lines,
337
+ this.state.cursorLine,
338
+ this.state.cursorCol,
339
+ selected,
340
+ this.autocompletePrefix,
341
+ );
342
+
343
+ this.state.lines = result.lines;
344
+ this.state.cursorLine = result.cursorLine;
345
+ this.state.cursorCol = result.cursorCol;
346
+ }
347
+ this.cancelAutocomplete();
348
+ // Don't return - fall through to submission logic
349
+ }
350
+ // If Enter was pressed on a file path, apply completion
351
+ else if (isEnter(data)) {
352
+ const selected = this.autocompleteList.getSelectedItem();
353
+ if (selected && this.autocompleteProvider) {
354
+ const result = this.autocompleteProvider.applyCompletion(
355
+ this.state.lines,
356
+ this.state.cursorLine,
357
+ this.state.cursorCol,
358
+ selected,
359
+ this.autocompletePrefix,
360
+ );
361
+
362
+ this.state.lines = result.lines;
363
+ this.state.cursorLine = result.cursorLine;
364
+ this.state.cursorCol = result.cursorCol;
365
+
366
+ this.cancelAutocomplete();
367
+
368
+ if (this.onChange) {
369
+ this.onChange(this.getText());
370
+ }
371
+ }
372
+ return;
373
+ }
374
+ }
375
+ // For other keys (like regular typing), DON'T return here
376
+ // Let them fall through to normal character handling
377
+ }
378
+
379
+ // Tab key - context-aware completion (but not when already autocompleting)
380
+ if (isTab(data) && !this.isAutocompleting) {
381
+ this.handleTabCompletion();
382
+ return;
383
+ }
384
+
385
+ // Continue with rest of input handling
386
+ // Ctrl+K - Delete to end of line
387
+ if (isCtrlK(data)) {
388
+ this.deleteToEndOfLine();
389
+ }
390
+ // Ctrl+U - Delete to start of line
391
+ else if (isCtrlU(data)) {
392
+ this.deleteToStartOfLine();
393
+ }
394
+ // Ctrl+W - Delete word backwards
395
+ else if (isCtrlW(data)) {
396
+ this.deleteWordBackwards();
397
+ }
398
+ // Option/Alt+Backspace - Delete word backwards
399
+ else if (isAltBackspace(data)) {
400
+ this.deleteWordBackwards();
401
+ }
402
+ // Ctrl+A - Move to start of line
403
+ else if (isCtrlA(data)) {
404
+ this.moveToLineStart();
405
+ }
406
+ // Ctrl+E - Move to end of line
407
+ else if (isCtrlE(data)) {
408
+ this.moveToLineEnd();
409
+ }
410
+ // New line shortcuts (but not plain LF/CR which should be submit)
411
+ else if (
412
+ (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
413
+ data === "\x1b\r" || // Option+Enter in some terminals (legacy)
414
+ data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
415
+ isShiftEnter(data) || // Shift+Enter (Kitty protocol, handles lock bits)
416
+ isAltEnter(data) || // Alt+Enter (Kitty protocol, handles lock bits)
417
+ (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
418
+ (data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping
419
+ data === "\\\r" // Shift+Enter in VS Code terminal
420
+ ) {
421
+ // Modifier + Enter = new line
422
+ this.addNewLine();
423
+ }
424
+ // Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
425
+ else if (isEnter(data)) {
426
+ // If submit is disabled, do nothing
427
+ if (this.disableSubmit) {
428
+ return;
429
+ }
430
+
431
+ // Get text and substitute paste markers with actual content
432
+ let result = this.state.lines.join("\n").trim();
433
+
434
+ // Replace all [paste #N +xxx lines] or [paste #N xxx chars] markers with actual paste content
435
+ for (const [pasteId, pasteContent] of this.pastes) {
436
+ // Match formats: [paste #N], [paste #N +xxx lines], or [paste #N xxx chars]
437
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
438
+ result = result.replace(markerRegex, pasteContent);
439
+ }
440
+
441
+ // Reset editor and clear pastes
442
+ this.state = {
443
+ lines: [""],
444
+ cursorLine: 0,
445
+ cursorCol: 0,
446
+ };
447
+ this.pastes.clear();
448
+ this.pasteCounter = 0;
449
+ this.historyIndex = -1; // Exit history browsing mode
450
+
451
+ // Notify that editor is now empty
452
+ if (this.onChange) {
453
+ this.onChange("");
454
+ }
455
+
456
+ if (this.onSubmit) {
457
+ this.onSubmit(result);
458
+ }
459
+ }
460
+ // Backspace
461
+ else if (isBackspace(data)) {
462
+ this.handleBackspace();
463
+ }
464
+ // Line navigation shortcuts (Home/End keys)
465
+ else if (isHome(data)) {
466
+ this.moveToLineStart();
467
+ } else if (isEnd(data)) {
468
+ this.moveToLineEnd();
469
+ }
470
+ // Forward delete (Fn+Backspace or Delete key)
471
+ else if (isDelete(data)) {
472
+ this.handleForwardDelete();
473
+ }
474
+ // Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
475
+ else if (isAltLeft(data) || isCtrlLeft(data)) {
476
+ // Word left
477
+ this.moveWordBackwards();
478
+ } else if (isAltRight(data) || isCtrlRight(data)) {
479
+ // Word right
480
+ this.moveWordForwards();
481
+ }
482
+ // Arrow keys
483
+ else if (isArrowUp(data)) {
484
+ // Up - history navigation or cursor movement
485
+ if (this.isEditorEmpty()) {
486
+ this.navigateHistory(-1); // Start browsing history
487
+ } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
488
+ this.navigateHistory(-1); // Navigate to older history entry
489
+ } else {
490
+ this.moveCursor(-1, 0); // Cursor movement (within text or history entry)
491
+ }
492
+ } else if (isArrowDown(data)) {
493
+ // Down - history navigation or cursor movement
494
+ if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
495
+ this.navigateHistory(1); // Navigate to newer history entry or clear
496
+ } else {
497
+ this.moveCursor(1, 0); // Cursor movement (within text or history entry)
498
+ }
499
+ } else if (isArrowRight(data)) {
500
+ // Right
501
+ this.moveCursor(0, 1);
502
+ } else if (isArrowLeft(data)) {
503
+ // Left
504
+ this.moveCursor(0, -1);
505
+ }
506
+ // Shift+Space via Kitty protocol (sends \x1b[32;2u instead of plain space)
507
+ else if (data === "\x1b[32;2u" || data.match(/^\x1b\[32;\d+u$/)) {
508
+ this.insertCharacter(" ");
509
+ }
510
+ // Regular characters (printable characters and unicode, but not control characters)
511
+ else if (data.charCodeAt(0) >= 32) {
512
+ this.insertCharacter(data);
513
+ }
514
+ }
515
+
516
+ private layoutText(contentWidth: number): LayoutLine[] {
517
+ const layoutLines: LayoutLine[] = [];
518
+
519
+ if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
520
+ // Empty editor
521
+ layoutLines.push({
522
+ text: "",
523
+ hasCursor: true,
524
+ cursorPos: 0,
525
+ });
526
+ return layoutLines;
527
+ }
528
+
529
+ // Process each logical line
530
+ for (let i = 0; i < this.state.lines.length; i++) {
531
+ const line = this.state.lines[i] || "";
532
+ const isCurrentLine = i === this.state.cursorLine;
533
+ const lineVisibleWidth = visibleWidth(line);
534
+
535
+ if (lineVisibleWidth <= contentWidth) {
536
+ // Line fits in one layout line
537
+ if (isCurrentLine) {
538
+ layoutLines.push({
539
+ text: line,
540
+ hasCursor: true,
541
+ cursorPos: this.state.cursorCol,
542
+ });
543
+ } else {
544
+ layoutLines.push({
545
+ text: line,
546
+ hasCursor: false,
547
+ });
548
+ }
549
+ } else {
550
+ // Line needs wrapping - use grapheme-aware chunking
551
+ const chunks: { text: string; startIndex: number; endIndex: number }[] = [];
552
+ let currentChunk = "";
553
+ let currentWidth = 0;
554
+ let chunkStartIndex = 0;
555
+ let currentIndex = 0;
556
+
557
+ for (const seg of segmenter.segment(line)) {
558
+ const grapheme = seg.segment;
559
+ const graphemeWidth = visibleWidth(grapheme);
560
+
561
+ if (currentWidth + graphemeWidth > contentWidth && currentChunk !== "") {
562
+ // Start a new chunk
563
+ chunks.push({
564
+ text: currentChunk,
565
+ startIndex: chunkStartIndex,
566
+ endIndex: currentIndex,
567
+ });
568
+ currentChunk = grapheme;
569
+ currentWidth = graphemeWidth;
570
+ chunkStartIndex = currentIndex;
571
+ } else {
572
+ currentChunk += grapheme;
573
+ currentWidth += graphemeWidth;
574
+ }
575
+ currentIndex += grapheme.length;
576
+ }
577
+
578
+ // Push the last chunk
579
+ if (currentChunk !== "") {
580
+ chunks.push({
581
+ text: currentChunk,
582
+ startIndex: chunkStartIndex,
583
+ endIndex: currentIndex,
584
+ });
585
+ }
586
+
587
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
588
+ const chunk = chunks[chunkIndex];
589
+ if (!chunk) continue;
590
+
591
+ const cursorPos = this.state.cursorCol;
592
+ const isLastChunk = chunkIndex === chunks.length - 1;
593
+ // For non-last chunks, cursor at endIndex belongs to the next chunk
594
+ const hasCursorInChunk =
595
+ isCurrentLine &&
596
+ cursorPos >= chunk.startIndex &&
597
+ (isLastChunk ? cursorPos <= chunk.endIndex : cursorPos < chunk.endIndex);
598
+
599
+ if (hasCursorInChunk) {
600
+ layoutLines.push({
601
+ text: chunk.text,
602
+ hasCursor: true,
603
+ cursorPos: cursorPos - chunk.startIndex,
604
+ });
605
+ } else {
606
+ layoutLines.push({
607
+ text: chunk.text,
608
+ hasCursor: false,
609
+ });
610
+ }
611
+ }
612
+ }
613
+ }
614
+
615
+ return layoutLines;
616
+ }
617
+
618
+ getText(): string {
619
+ return this.state.lines.join("\n");
620
+ }
621
+
622
+ getLines(): string[] {
623
+ return [...this.state.lines];
624
+ }
625
+
626
+ getCursor(): { line: number; col: number } {
627
+ return { line: this.state.cursorLine, col: this.state.cursorCol };
628
+ }
629
+
630
+ setText(text: string): void {
631
+ this.historyIndex = -1; // Exit history browsing mode
632
+ this.setTextInternal(text);
633
+ }
634
+
635
+ /** Insert text at the current cursor position */
636
+ insertText(text: string): void {
637
+ this.historyIndex = -1;
638
+
639
+ const line = this.state.lines[this.state.cursorLine] || "";
640
+ const before = line.slice(0, this.state.cursorCol);
641
+ const after = line.slice(this.state.cursorCol);
642
+
643
+ this.state.lines[this.state.cursorLine] = before + text + after;
644
+ this.state.cursorCol += text.length;
645
+
646
+ if (this.onChange) {
647
+ this.onChange(this.getText());
648
+ }
649
+ }
650
+
651
+ // All the editor methods from before...
652
+ private insertCharacter(char: string): void {
653
+ this.historyIndex = -1; // Exit history browsing mode
654
+
655
+ const line = this.state.lines[this.state.cursorLine] || "";
656
+
657
+ const before = line.slice(0, this.state.cursorCol);
658
+ const after = line.slice(this.state.cursorCol);
659
+
660
+ this.state.lines[this.state.cursorLine] = before + char + after;
661
+ this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string
662
+
663
+ if (this.onChange) {
664
+ this.onChange(this.getText());
665
+ }
666
+
667
+ // Check if we should trigger or update autocomplete
668
+ if (!this.isAutocompleting) {
669
+ // Auto-trigger for "/" at the start of a line (slash commands)
670
+ if (char === "/" && this.isAtStartOfMessage()) {
671
+ this.tryTriggerAutocomplete();
672
+ }
673
+ // Auto-trigger for "@" file reference (fuzzy search)
674
+ else if (char === "@") {
675
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
676
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
677
+ // Only trigger if @ is after whitespace or at start of line
678
+ const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
679
+ if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
680
+ this.tryTriggerAutocomplete();
681
+ }
682
+ }
683
+ // Also auto-trigger when typing letters in a slash command context
684
+ else if (/[a-zA-Z0-9]/.test(char)) {
685
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
686
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
687
+ // Check if we're in a slash command (with or without space for arguments)
688
+ if (textBeforeCursor.trimStart().startsWith("/")) {
689
+ this.tryTriggerAutocomplete();
690
+ }
691
+ // Check if we're in an @ file reference context
692
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
693
+ this.tryTriggerAutocomplete();
694
+ }
695
+ }
696
+ } else {
697
+ this.updateAutocomplete();
698
+ }
699
+ }
700
+
701
+ private handlePaste(pastedText: string): void {
702
+ this.historyIndex = -1; // Exit history browsing mode
703
+
704
+ // Clean the pasted text
705
+ const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
706
+
707
+ // Convert tabs to spaces (4 spaces per tab)
708
+ const tabExpandedText = cleanText.replace(/\t/g, " ");
709
+
710
+ // Filter out non-printable characters except newlines
711
+ let filteredText = tabExpandedText
712
+ .split("")
713
+ .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
714
+ .join("");
715
+
716
+ // If pasting a file path (starts with /, ~, or .) and the character before
717
+ // the cursor is a word character, prepend a space for better readability
718
+ if (/^[/~.]/.test(filteredText)) {
719
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
720
+ const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : "";
721
+ if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
722
+ filteredText = ` ${filteredText}`;
723
+ }
724
+ }
725
+
726
+ // Split into lines
727
+ const pastedLines = filteredText.split("\n");
728
+
729
+ // Check if this is a large paste (> 10 lines or > 1000 characters)
730
+ const totalChars = filteredText.length;
731
+ if (pastedLines.length > 10 || totalChars > 1000) {
732
+ // Store the paste and insert a marker
733
+ this.pasteCounter++;
734
+ const pasteId = this.pasteCounter;
735
+ this.pastes.set(pasteId, filteredText);
736
+
737
+ // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
738
+ const marker =
739
+ pastedLines.length > 10
740
+ ? `[paste #${pasteId} +${pastedLines.length} lines]`
741
+ : `[paste #${pasteId} ${totalChars} chars]`;
742
+ for (const char of marker) {
743
+ this.insertCharacter(char);
744
+ }
745
+
746
+ return;
747
+ }
748
+
749
+ if (pastedLines.length === 1) {
750
+ // Single line - just insert each character
751
+ const text = pastedLines[0] || "";
752
+ for (const char of text) {
753
+ this.insertCharacter(char);
754
+ }
755
+
756
+ return;
757
+ }
758
+
759
+ // Multi-line paste - be very careful with array manipulation
760
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
761
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
762
+ const afterCursor = currentLine.slice(this.state.cursorCol);
763
+
764
+ // Build the new lines array step by step
765
+ const newLines: string[] = [];
766
+
767
+ // Add all lines before current line
768
+ for (let i = 0; i < this.state.cursorLine; i++) {
769
+ newLines.push(this.state.lines[i] || "");
770
+ }
771
+
772
+ // Add the first pasted line merged with before cursor text
773
+ newLines.push(beforeCursor + (pastedLines[0] || ""));
774
+
775
+ // Add all middle pasted lines
776
+ for (let i = 1; i < pastedLines.length - 1; i++) {
777
+ newLines.push(pastedLines[i] || "");
778
+ }
779
+
780
+ // Add the last pasted line with after cursor text
781
+ newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
782
+
783
+ // Add all lines after current line
784
+ for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
785
+ newLines.push(this.state.lines[i] || "");
786
+ }
787
+
788
+ // Replace the entire lines array
789
+ this.state.lines = newLines;
790
+
791
+ // Update cursor position to end of pasted content
792
+ this.state.cursorLine += pastedLines.length - 1;
793
+ this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
794
+
795
+ // Notify of change
796
+ if (this.onChange) {
797
+ this.onChange(this.getText());
798
+ }
799
+ }
800
+
801
+ private addNewLine(): void {
802
+ this.historyIndex = -1; // Exit history browsing mode
803
+
804
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
805
+
806
+ const before = currentLine.slice(0, this.state.cursorCol);
807
+ const after = currentLine.slice(this.state.cursorCol);
808
+
809
+ // Split current line
810
+ this.state.lines[this.state.cursorLine] = before;
811
+ this.state.lines.splice(this.state.cursorLine + 1, 0, after);
812
+
813
+ // Move cursor to start of new line
814
+ this.state.cursorLine++;
815
+ this.state.cursorCol = 0;
816
+
817
+ if (this.onChange) {
818
+ this.onChange(this.getText());
819
+ }
820
+ }
821
+
822
+ private handleBackspace(): void {
823
+ this.historyIndex = -1; // Exit history browsing mode
824
+
825
+ if (this.state.cursorCol > 0) {
826
+ // Delete grapheme before cursor (handles emojis, combining characters, etc.)
827
+ const line = this.state.lines[this.state.cursorLine] || "";
828
+ const beforeCursor = line.slice(0, this.state.cursorCol);
829
+
830
+ // Find the last grapheme in the text before cursor
831
+ const graphemes = [...segmenter.segment(beforeCursor)];
832
+ const lastGrapheme = graphemes[graphemes.length - 1];
833
+ const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
834
+
835
+ const before = line.slice(0, this.state.cursorCol - graphemeLength);
836
+ const after = line.slice(this.state.cursorCol);
837
+
838
+ this.state.lines[this.state.cursorLine] = before + after;
839
+ this.state.cursorCol -= graphemeLength;
840
+ } else if (this.state.cursorLine > 0) {
841
+ // Merge with previous line
842
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
843
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
844
+
845
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
846
+ this.state.lines.splice(this.state.cursorLine, 1);
847
+
848
+ this.state.cursorLine--;
849
+ this.state.cursorCol = previousLine.length;
850
+ }
851
+
852
+ if (this.onChange) {
853
+ this.onChange(this.getText());
854
+ }
855
+
856
+ // Update or re-trigger autocomplete after backspace
857
+ if (this.isAutocompleting) {
858
+ this.updateAutocomplete();
859
+ } else {
860
+ // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
861
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
862
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
863
+ // Slash command context
864
+ if (textBeforeCursor.trimStart().startsWith("/")) {
865
+ this.tryTriggerAutocomplete();
866
+ }
867
+ // @ file reference context
868
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
869
+ this.tryTriggerAutocomplete();
870
+ }
871
+ }
872
+ }
873
+
874
+ private moveToLineStart(): void {
875
+ this.state.cursorCol = 0;
876
+ }
877
+
878
+ private moveToLineEnd(): void {
879
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
880
+ this.state.cursorCol = currentLine.length;
881
+ }
882
+
883
+ private deleteToStartOfLine(): void {
884
+ this.historyIndex = -1; // Exit history browsing mode
885
+
886
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
887
+
888
+ if (this.state.cursorCol > 0) {
889
+ // Delete from start of line up to cursor
890
+ this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
891
+ this.state.cursorCol = 0;
892
+ } else if (this.state.cursorLine > 0) {
893
+ // At start of line - merge with previous line
894
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
895
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
896
+ this.state.lines.splice(this.state.cursorLine, 1);
897
+ this.state.cursorLine--;
898
+ this.state.cursorCol = previousLine.length;
899
+ }
900
+
901
+ if (this.onChange) {
902
+ this.onChange(this.getText());
903
+ }
904
+ }
905
+
906
+ private deleteToEndOfLine(): void {
907
+ this.historyIndex = -1; // Exit history browsing mode
908
+
909
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
910
+
911
+ if (this.state.cursorCol < currentLine.length) {
912
+ // Delete from cursor to end of line
913
+ this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
914
+ } else if (this.state.cursorLine < this.state.lines.length - 1) {
915
+ // At end of line - merge with next line
916
+ const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
917
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine;
918
+ this.state.lines.splice(this.state.cursorLine + 1, 1);
919
+ }
920
+
921
+ if (this.onChange) {
922
+ this.onChange(this.getText());
923
+ }
924
+ }
925
+
926
+ private deleteWordBackwards(): void {
927
+ this.historyIndex = -1; // Exit history browsing mode
928
+
929
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
930
+
931
+ // If at start of line, behave like backspace at column 0 (merge with previous line)
932
+ if (this.state.cursorCol === 0) {
933
+ if (this.state.cursorLine > 0) {
934
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
935
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
936
+ this.state.lines.splice(this.state.cursorLine, 1);
937
+ this.state.cursorLine--;
938
+ this.state.cursorCol = previousLine.length;
939
+ }
940
+ } else {
941
+ const oldCursorCol = this.state.cursorCol;
942
+ this.moveWordBackwards();
943
+ const deleteFrom = this.state.cursorCol;
944
+ this.state.cursorCol = oldCursorCol;
945
+
946
+ this.state.lines[this.state.cursorLine] =
947
+ currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
948
+ this.state.cursorCol = deleteFrom;
949
+ }
950
+
951
+ if (this.onChange) {
952
+ this.onChange(this.getText());
953
+ }
954
+ }
955
+
956
+ private handleForwardDelete(): void {
957
+ this.historyIndex = -1; // Exit history browsing mode
958
+
959
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
960
+
961
+ if (this.state.cursorCol < currentLine.length) {
962
+ // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
963
+ const afterCursor = currentLine.slice(this.state.cursorCol);
964
+
965
+ // Find the first grapheme at cursor
966
+ const graphemes = [...segmenter.segment(afterCursor)];
967
+ const firstGrapheme = graphemes[0];
968
+ const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
969
+
970
+ const before = currentLine.slice(0, this.state.cursorCol);
971
+ const after = currentLine.slice(this.state.cursorCol + graphemeLength);
972
+ this.state.lines[this.state.cursorLine] = before + after;
973
+ } else if (this.state.cursorLine < this.state.lines.length - 1) {
974
+ // At end of line - merge with next line
975
+ const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
976
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine;
977
+ this.state.lines.splice(this.state.cursorLine + 1, 1);
978
+ }
979
+
980
+ if (this.onChange) {
981
+ this.onChange(this.getText());
982
+ }
983
+
984
+ // Update or re-trigger autocomplete after forward delete
985
+ if (this.isAutocompleting) {
986
+ this.updateAutocomplete();
987
+ } else {
988
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
989
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
990
+ // Slash command context
991
+ if (textBeforeCursor.trimStart().startsWith("/")) {
992
+ this.tryTriggerAutocomplete();
993
+ }
994
+ // @ file reference context
995
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
996
+ this.tryTriggerAutocomplete();
997
+ }
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * Build a mapping from visual lines to logical positions.
1003
+ * Returns an array where each element represents a visual line with:
1004
+ * - logicalLine: index into this.state.lines
1005
+ * - startCol: starting column in the logical line
1006
+ * - length: length of this visual line segment
1007
+ */
1008
+ private buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> {
1009
+ const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = [];
1010
+
1011
+ for (let i = 0; i < this.state.lines.length; i++) {
1012
+ const line = this.state.lines[i] || "";
1013
+ const lineVisWidth = visibleWidth(line);
1014
+ if (line.length === 0) {
1015
+ // Empty line still takes one visual line
1016
+ visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
1017
+ } else if (lineVisWidth <= width) {
1018
+ visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
1019
+ } else {
1020
+ // Line needs wrapping - use grapheme-aware chunking
1021
+ let currentWidth = 0;
1022
+ let chunkStartIndex = 0;
1023
+ let currentIndex = 0;
1024
+
1025
+ for (const seg of segmenter.segment(line)) {
1026
+ const grapheme = seg.segment;
1027
+ const graphemeWidth = visibleWidth(grapheme);
1028
+
1029
+ if (currentWidth + graphemeWidth > width && currentIndex > chunkStartIndex) {
1030
+ // Start a new chunk
1031
+ visualLines.push({
1032
+ logicalLine: i,
1033
+ startCol: chunkStartIndex,
1034
+ length: currentIndex - chunkStartIndex,
1035
+ });
1036
+ chunkStartIndex = currentIndex;
1037
+ currentWidth = graphemeWidth;
1038
+ } else {
1039
+ currentWidth += graphemeWidth;
1040
+ }
1041
+ currentIndex += grapheme.length;
1042
+ }
1043
+
1044
+ // Push the last chunk
1045
+ if (currentIndex > chunkStartIndex) {
1046
+ visualLines.push({
1047
+ logicalLine: i,
1048
+ startCol: chunkStartIndex,
1049
+ length: currentIndex - chunkStartIndex,
1050
+ });
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ return visualLines;
1056
+ }
1057
+
1058
+ /**
1059
+ * Find the visual line index for the current cursor position.
1060
+ */
1061
+ private findCurrentVisualLine(
1062
+ visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
1063
+ ): number {
1064
+ for (let i = 0; i < visualLines.length; i++) {
1065
+ const vl = visualLines[i];
1066
+ if (!vl) continue;
1067
+ if (vl.logicalLine === this.state.cursorLine) {
1068
+ const colInSegment = this.state.cursorCol - vl.startCol;
1069
+ // Cursor is in this segment if it's within range
1070
+ // For the last segment of a logical line, cursor can be at length (end position)
1071
+ const isLastSegmentOfLine =
1072
+ i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1073
+ if (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {
1074
+ return i;
1075
+ }
1076
+ }
1077
+ }
1078
+ // Fallback: return last visual line
1079
+ return visualLines.length - 1;
1080
+ }
1081
+
1082
+ private moveCursor(deltaLine: number, deltaCol: number): void {
1083
+ const width = this.lastWidth;
1084
+
1085
+ if (deltaLine !== 0) {
1086
+ // Build visual line map for navigation
1087
+ const visualLines = this.buildVisualLineMap(width);
1088
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
1089
+
1090
+ // Calculate column position within current visual line
1091
+ const currentVL = visualLines[currentVisualLine];
1092
+ const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0;
1093
+
1094
+ // Move to target visual line
1095
+ const targetVisualLine = currentVisualLine + deltaLine;
1096
+
1097
+ if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
1098
+ const targetVL = visualLines[targetVisualLine];
1099
+ if (targetVL) {
1100
+ this.state.cursorLine = targetVL.logicalLine;
1101
+ // Try to maintain visual column position, clamped to line length
1102
+ const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length);
1103
+ const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1104
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ if (deltaCol !== 0) {
1110
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1111
+
1112
+ if (deltaCol > 0) {
1113
+ // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1114
+ if (this.state.cursorCol < currentLine.length) {
1115
+ const afterCursor = currentLine.slice(this.state.cursorCol);
1116
+ const graphemes = [...segmenter.segment(afterCursor)];
1117
+ const firstGrapheme = graphemes[0];
1118
+ this.state.cursorCol += firstGrapheme ? firstGrapheme.segment.length : 1;
1119
+ } else if (this.state.cursorLine < this.state.lines.length - 1) {
1120
+ // Wrap to start of next logical line
1121
+ this.state.cursorLine++;
1122
+ this.state.cursorCol = 0;
1123
+ }
1124
+ } else {
1125
+ // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1126
+ if (this.state.cursorCol > 0) {
1127
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1128
+ const graphemes = [...segmenter.segment(beforeCursor)];
1129
+ const lastGrapheme = graphemes[graphemes.length - 1];
1130
+ this.state.cursorCol -= lastGrapheme ? lastGrapheme.segment.length : 1;
1131
+ } else if (this.state.cursorLine > 0) {
1132
+ // Wrap to end of previous logical line
1133
+ this.state.cursorLine--;
1134
+ const prevLine = this.state.lines[this.state.cursorLine] || "";
1135
+ this.state.cursorCol = prevLine.length;
1136
+ }
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ private moveWordBackwards(): void {
1142
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1143
+
1144
+ // If at start of line, move to end of previous line
1145
+ if (this.state.cursorCol === 0) {
1146
+ if (this.state.cursorLine > 0) {
1147
+ this.state.cursorLine--;
1148
+ const prevLine = this.state.lines[this.state.cursorLine] || "";
1149
+ this.state.cursorCol = prevLine.length;
1150
+ }
1151
+ return;
1152
+ }
1153
+
1154
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1155
+ const graphemes = [...segmenter.segment(textBeforeCursor)];
1156
+ let newCol = this.state.cursorCol;
1157
+
1158
+ // Skip trailing whitespace
1159
+ while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1160
+ newCol -= graphemes.pop()?.segment.length || 0;
1161
+ }
1162
+
1163
+ if (graphemes.length > 0) {
1164
+ const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
1165
+ if (isPunctuationChar(lastGrapheme)) {
1166
+ // Skip punctuation run
1167
+ while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1168
+ newCol -= graphemes.pop()?.segment.length || 0;
1169
+ }
1170
+ } else {
1171
+ // Skip word run
1172
+ while (
1173
+ graphemes.length > 0 &&
1174
+ !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
1175
+ !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
1176
+ ) {
1177
+ newCol -= graphemes.pop()?.segment.length || 0;
1178
+ }
1179
+ }
1180
+ }
1181
+
1182
+ this.state.cursorCol = newCol;
1183
+ }
1184
+
1185
+ private moveWordForwards(): void {
1186
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1187
+
1188
+ // If at end of line, move to start of next line
1189
+ if (this.state.cursorCol >= currentLine.length) {
1190
+ if (this.state.cursorLine < this.state.lines.length - 1) {
1191
+ this.state.cursorLine++;
1192
+ this.state.cursorCol = 0;
1193
+ }
1194
+ return;
1195
+ }
1196
+
1197
+ const textAfterCursor = currentLine.slice(this.state.cursorCol);
1198
+ const segments = segmenter.segment(textAfterCursor);
1199
+ const iterator = segments[Symbol.iterator]();
1200
+ let next = iterator.next();
1201
+
1202
+ // Skip leading whitespace
1203
+ while (!next.done && isWhitespaceChar(next.value.segment)) {
1204
+ this.state.cursorCol += next.value.segment.length;
1205
+ next = iterator.next();
1206
+ }
1207
+
1208
+ if (!next.done) {
1209
+ const firstGrapheme = next.value.segment;
1210
+ if (isPunctuationChar(firstGrapheme)) {
1211
+ // Skip punctuation run
1212
+ while (!next.done && isPunctuationChar(next.value.segment)) {
1213
+ this.state.cursorCol += next.value.segment.length;
1214
+ next = iterator.next();
1215
+ }
1216
+ } else {
1217
+ // Skip word run
1218
+ while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
1219
+ this.state.cursorCol += next.value.segment.length;
1220
+ next = iterator.next();
1221
+ }
1222
+ }
1223
+ }
1224
+ }
1225
+
1226
+ // Helper method to check if cursor is at start of message (for slash command detection)
1227
+ private isAtStartOfMessage(): boolean {
1228
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1229
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1230
+
1231
+ // At start if line is empty, only contains whitespace, or is just "/"
1232
+ return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
1233
+ }
1234
+
1235
+ // Autocomplete methods
1236
+ private tryTriggerAutocomplete(explicitTab: boolean = false): void {
1237
+ if (!this.autocompleteProvider) return;
1238
+
1239
+ // Check if we should trigger file completion on Tab
1240
+ if (explicitTab) {
1241
+ const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
1242
+ const shouldTrigger =
1243
+ !provider.shouldTriggerFileCompletion ||
1244
+ provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1245
+ if (!shouldTrigger) {
1246
+ return;
1247
+ }
1248
+ }
1249
+
1250
+ const suggestions = this.autocompleteProvider.getSuggestions(
1251
+ this.state.lines,
1252
+ this.state.cursorLine,
1253
+ this.state.cursorCol,
1254
+ );
1255
+
1256
+ if (suggestions && suggestions.items.length > 0) {
1257
+ this.autocompletePrefix = suggestions.prefix;
1258
+ this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
1259
+ this.isAutocompleting = true;
1260
+ } else {
1261
+ this.cancelAutocomplete();
1262
+ }
1263
+ }
1264
+
1265
+ private handleTabCompletion(): void {
1266
+ if (!this.autocompleteProvider) return;
1267
+
1268
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1269
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1270
+
1271
+ // Check if we're in a slash command context
1272
+ if (beforeCursor.trimStart().startsWith("/") && !beforeCursor.trimStart().includes(" ")) {
1273
+ this.handleSlashCommandCompletion();
1274
+ } else {
1275
+ this.forceFileAutocomplete();
1276
+ }
1277
+ }
1278
+
1279
+ private handleSlashCommandCompletion(): void {
1280
+ this.tryTriggerAutocomplete(true);
1281
+ }
1282
+
1283
+ /*
1284
+ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
1285
+ 17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
1286
+ 536643416/job/55932288317 havea look at .gi
1287
+ */
1288
+ private forceFileAutocomplete(): void {
1289
+ if (!this.autocompleteProvider) return;
1290
+
1291
+ // Check if provider supports force file suggestions via runtime check
1292
+ const provider = this.autocompleteProvider as {
1293
+ getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"];
1294
+ };
1295
+ if (typeof provider.getForceFileSuggestions !== "function") {
1296
+ this.tryTriggerAutocomplete(true);
1297
+ return;
1298
+ }
1299
+
1300
+ const suggestions = provider.getForceFileSuggestions(
1301
+ this.state.lines,
1302
+ this.state.cursorLine,
1303
+ this.state.cursorCol,
1304
+ );
1305
+
1306
+ if (suggestions && suggestions.items.length > 0) {
1307
+ this.autocompletePrefix = suggestions.prefix;
1308
+ this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
1309
+ this.isAutocompleting = true;
1310
+ } else {
1311
+ this.cancelAutocomplete();
1312
+ }
1313
+ }
1314
+
1315
+ private cancelAutocomplete(): void {
1316
+ this.isAutocompleting = false;
1317
+ this.autocompleteList = undefined;
1318
+ this.autocompletePrefix = "";
1319
+ }
1320
+
1321
+ public isShowingAutocomplete(): boolean {
1322
+ return this.isAutocompleting;
1323
+ }
1324
+
1325
+ private updateAutocomplete(): void {
1326
+ if (!this.isAutocompleting || !this.autocompleteProvider) return;
1327
+
1328
+ const suggestions = this.autocompleteProvider.getSuggestions(
1329
+ this.state.lines,
1330
+ this.state.cursorLine,
1331
+ this.state.cursorCol,
1332
+ );
1333
+
1334
+ if (suggestions && suggestions.items.length > 0) {
1335
+ this.autocompletePrefix = suggestions.prefix;
1336
+ // Always create new SelectList to ensure update
1337
+ this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
1338
+ } else {
1339
+ this.cancelAutocomplete();
1340
+ }
1341
+ }
1342
+ }