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