@gajae-code/tui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +818 -0
  2. package/README.md +704 -0
  3. package/dist/types/autocomplete.d.ts +76 -0
  4. package/dist/types/bracketed-paste.d.ts +26 -0
  5. package/dist/types/components/box.d.ts +15 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +101 -0
  8. package/dist/types/components/image.d.ts +16 -0
  9. package/dist/types/components/input.d.ts +16 -0
  10. package/dist/types/components/loader.d.ts +13 -0
  11. package/dist/types/components/markdown.d.ts +61 -0
  12. package/dist/types/components/select-list.d.ts +46 -0
  13. package/dist/types/components/settings-list.d.ts +39 -0
  14. package/dist/types/components/spacer.d.ts +11 -0
  15. package/dist/types/components/tab-bar.d.ts +56 -0
  16. package/dist/types/components/text.d.ts +13 -0
  17. package/dist/types/components/truncated-text.d.ts +10 -0
  18. package/dist/types/editor-component.d.ts +36 -0
  19. package/dist/types/fuzzy.d.ts +15 -0
  20. package/dist/types/index.d.ts +25 -0
  21. package/dist/types/keybindings.d.ts +189 -0
  22. package/dist/types/keys.d.ts +208 -0
  23. package/dist/types/kill-ring.d.ts +27 -0
  24. package/dist/types/stdin-buffer.d.ts +43 -0
  25. package/dist/types/symbols.d.ts +23 -0
  26. package/dist/types/terminal-capabilities.d.ts +75 -0
  27. package/dist/types/terminal.d.ts +61 -0
  28. package/dist/types/ttyid.d.ts +9 -0
  29. package/dist/types/tui.d.ts +161 -0
  30. package/dist/types/utils.d.ts +74 -0
  31. package/package.json +73 -0
  32. package/src/autocomplete.ts +836 -0
  33. package/src/bracketed-paste.ts +47 -0
  34. package/src/components/box.ts +144 -0
  35. package/src/components/cancellable-loader.ts +40 -0
  36. package/src/components/editor.ts +2664 -0
  37. package/src/components/image.ts +90 -0
  38. package/src/components/input.ts +465 -0
  39. package/src/components/loader.ts +86 -0
  40. package/src/components/markdown.ts +1009 -0
  41. package/src/components/select-list.ts +249 -0
  42. package/src/components/settings-list.ts +211 -0
  43. package/src/components/spacer.ts +28 -0
  44. package/src/components/tab-bar.ts +175 -0
  45. package/src/components/text.ts +110 -0
  46. package/src/components/truncated-text.ts +61 -0
  47. package/src/editor-component.ts +71 -0
  48. package/src/fuzzy.ts +143 -0
  49. package/src/index.ts +39 -0
  50. package/src/keybindings.ts +279 -0
  51. package/src/keys.ts +537 -0
  52. package/src/kill-ring.ts +46 -0
  53. package/src/stdin-buffer.ts +410 -0
  54. package/src/symbols.ts +24 -0
  55. package/src/terminal-capabilities.ts +537 -0
  56. package/src/terminal.ts +716 -0
  57. package/src/ttyid.ts +66 -0
  58. package/src/tui.ts +1481 -0
  59. package/src/utils.ts +359 -0
@@ -0,0 +1,2664 @@
1
+ import { getProjectDir, logger } from "@gajae-code/utils";
2
+ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
3
+ import { BracketedPasteHandler } from "../bracketed-paste";
4
+ import { getKeybindings, type KeybindingsManager } from "../keybindings";
5
+ import { extractPrintableText, matchesKey } from "../keys";
6
+ import { KillRing } from "../kill-ring";
7
+ import type { SymbolTheme } from "../symbols";
8
+ import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
9
+ import {
10
+ getSegmenter,
11
+ getWordNavKind,
12
+ moveWordLeft,
13
+ moveWordRight,
14
+ padding,
15
+ replaceTabs,
16
+ sliceByColumn,
17
+ truncateToWidth,
18
+ visibleWidth,
19
+ } from "../utils";
20
+ import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
21
+
22
+ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
23
+ minPrimaryColumnWidth: 12,
24
+ maxPrimaryColumnWidth: 32,
25
+ };
26
+
27
+ function sanitizeLoadedText(text: string): string {
28
+ // Normalize CRLF/CR → LF, then strip C0 control chars except \n.
29
+ return replaceTabs(text.replace(/\r\n?/g, "\n"))
30
+ .replace(/[\x00-\x09\x0b-\x1f]/g, "")
31
+ .normalize("NFC");
32
+ }
33
+
34
+ function insertTextNfcAt(line: string, cursorCol: number, text: string): { line: string; cursorCol: number } {
35
+ const before = line.slice(0, cursorCol);
36
+ const after = line.slice(cursorCol);
37
+ const beforeWithInsert = (before + text).normalize("NFC");
38
+ return {
39
+ line: (beforeWithInsert + after).normalize("NFC"),
40
+ cursorCol: beforeWithInsert.length,
41
+ };
42
+ }
43
+
44
+ const segmenter = getSegmenter();
45
+
46
+ /**
47
+ * Represents a chunk of text for word-wrap layout.
48
+ * Tracks both the text content and its position in the original line.
49
+ */
50
+ interface TextChunk {
51
+ text: string;
52
+ startIndex: number;
53
+ endIndex: number;
54
+ }
55
+
56
+ /**
57
+ * Split a line into word-wrapped chunks.
58
+ * Wraps at word boundaries when possible, falling back to character-level
59
+ * wrapping for words longer than the available width.
60
+ *
61
+ * @param line - The text line to wrap
62
+ * @param maxWidth - Maximum visible width per chunk
63
+ * @returns Array of chunks with text and position information
64
+ */
65
+ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
66
+ if (!line || maxWidth <= 0) {
67
+ return [{ text: "", startIndex: 0, endIndex: 0 }];
68
+ }
69
+
70
+ const lineWidth = visibleWidth(line);
71
+ if (lineWidth <= maxWidth) {
72
+ return [{ text: line, startIndex: 0, endIndex: line.length }];
73
+ }
74
+
75
+ const chunks: TextChunk[] = [];
76
+
77
+ // Split into tokens (words and whitespace runs)
78
+ const tokens: { text: string; startIndex: number; endIndex: number; isWhitespace: boolean }[] = [];
79
+ let currentToken = "";
80
+ let tokenStart = 0;
81
+ let inWhitespace = false;
82
+ let charIndex = 0;
83
+
84
+ for (const seg of segmenter.segment(line)) {
85
+ const grapheme = seg.segment;
86
+ const graphemeIsWhitespace = getWordNavKind(grapheme) === "whitespace";
87
+
88
+ if (currentToken === "") {
89
+ inWhitespace = graphemeIsWhitespace;
90
+ tokenStart = charIndex;
91
+ } else if (graphemeIsWhitespace !== inWhitespace) {
92
+ // Token type changed - save current token
93
+ tokens.push({
94
+ text: currentToken,
95
+ startIndex: tokenStart,
96
+ endIndex: charIndex,
97
+ isWhitespace: inWhitespace,
98
+ });
99
+ currentToken = "";
100
+ tokenStart = charIndex;
101
+ inWhitespace = graphemeIsWhitespace;
102
+ }
103
+
104
+ currentToken += grapheme;
105
+ charIndex += grapheme.length;
106
+ }
107
+
108
+ // Push final token
109
+ if (currentToken) {
110
+ tokens.push({
111
+ text: currentToken,
112
+ startIndex: tokenStart,
113
+ endIndex: charIndex,
114
+ isWhitespace: inWhitespace,
115
+ });
116
+ }
117
+
118
+ // Build chunks using word wrapping
119
+ let currentChunk = "";
120
+ let currentWidth = 0;
121
+ let chunkStartIndex = 0;
122
+ let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)
123
+
124
+ function consumePrefixToWidth(text: string, availableWidth: number): { text: string; len: number } {
125
+ let prefix = "";
126
+ let prefixWidth = 0;
127
+ let len = 0;
128
+ for (const seg of segmenter.segment(text)) {
129
+ const grapheme = seg.segment;
130
+ const graphemeWidth = visibleWidth(grapheme);
131
+ if (prefixWidth + graphemeWidth > availableWidth) break;
132
+ prefix += grapheme;
133
+ prefixWidth += graphemeWidth;
134
+ len += grapheme.length;
135
+ if (prefixWidth === availableWidth) break;
136
+ }
137
+ return { text: prefix, len };
138
+ }
139
+ function hasWideGrapheme(text: string): boolean {
140
+ for (const seg of segmenter.segment(text)) {
141
+ if (visibleWidth(seg.segment) > 1) return true;
142
+ }
143
+ return false;
144
+ }
145
+ for (const token of tokens) {
146
+ const tokenWidth = visibleWidth(token.text);
147
+
148
+ // Skip leading whitespace at line start
149
+ if (atLineStart && token.isWhitespace) {
150
+ chunkStartIndex = token.endIndex;
151
+ continue;
152
+ }
153
+ atLineStart = false;
154
+
155
+ // If this single token is wider than maxWidth, we need to break it
156
+ if (tokenWidth > maxWidth) {
157
+ // If we're mid-line, try to use the remaining width by consuming a prefix of this long token.
158
+ let consumedPrefix = "";
159
+ let consumedPrefixLen = 0; // JS string index (code units) consumed from token.text
160
+ if (currentChunk && currentWidth < maxWidth) {
161
+ const remainingWidth = maxWidth - currentWidth;
162
+ const consumed = consumePrefixToWidth(token.text, remainingWidth);
163
+ consumedPrefix = consumed.text;
164
+ consumedPrefixLen = consumed.len;
165
+ }
166
+ // First, push any accumulated chunk (optionally filled with the prefix).
167
+ if (currentChunk) {
168
+ if (consumedPrefix) {
169
+ chunks.push({
170
+ text: currentChunk + consumedPrefix,
171
+ startIndex: chunkStartIndex,
172
+ endIndex: token.startIndex + consumedPrefixLen,
173
+ });
174
+ currentChunk = "";
175
+ currentWidth = 0;
176
+ chunkStartIndex = token.startIndex + consumedPrefixLen;
177
+ } else {
178
+ chunks.push({
179
+ text: currentChunk,
180
+ startIndex: chunkStartIndex,
181
+ endIndex: token.startIndex,
182
+ });
183
+ currentChunk = "";
184
+ currentWidth = 0;
185
+ chunkStartIndex = token.startIndex;
186
+ }
187
+ }
188
+ // Break the remaining long token by grapheme
189
+ const remainingText = consumedPrefixLen > 0 ? token.text.slice(consumedPrefixLen) : token.text;
190
+ let tokenChunk = "";
191
+ let tokenChunkWidth = 0;
192
+ let tokenChunkStart = token.startIndex + consumedPrefixLen;
193
+ let tokenCharIndex = token.startIndex + consumedPrefixLen;
194
+ for (const seg of segmenter.segment(remainingText)) {
195
+ const grapheme = seg.segment;
196
+ const graphemeWidth = visibleWidth(grapheme);
197
+ if (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) {
198
+ chunks.push({
199
+ text: tokenChunk,
200
+ startIndex: tokenChunkStart,
201
+ endIndex: tokenCharIndex,
202
+ });
203
+ tokenChunk = grapheme;
204
+ tokenChunkWidth = graphemeWidth;
205
+ tokenChunkStart = tokenCharIndex;
206
+ } else {
207
+ tokenChunk += grapheme;
208
+ tokenChunkWidth += graphemeWidth;
209
+ }
210
+ tokenCharIndex += grapheme.length;
211
+ }
212
+ // Keep remainder as start of next chunk
213
+ if (tokenChunk) {
214
+ currentChunk = tokenChunk;
215
+ currentWidth = tokenChunkWidth;
216
+ chunkStartIndex = tokenChunkStart;
217
+ }
218
+ continue;
219
+ }
220
+
221
+ // Check if adding this token would exceed width
222
+ if (currentWidth + tokenWidth > maxWidth) {
223
+ // For wide-character tokens (e.g., CJK runs), prefer using remaining width before wrapping
224
+ // the whole token to the next line. This avoids leaving a short ASCII word alone.
225
+ if (currentChunk && !token.isWhitespace && currentWidth < maxWidth && hasWideGrapheme(token.text)) {
226
+ const remainingWidth = maxWidth - currentWidth;
227
+ const consumed = consumePrefixToWidth(token.text, remainingWidth);
228
+ if (consumed.text) {
229
+ chunks.push({
230
+ text: currentChunk + consumed.text,
231
+ startIndex: chunkStartIndex,
232
+ endIndex: token.startIndex + consumed.len,
233
+ });
234
+ const remainder = token.text.slice(consumed.len);
235
+ currentChunk = remainder;
236
+ currentWidth = visibleWidth(remainder);
237
+ chunkStartIndex = token.startIndex + consumed.len;
238
+ atLineStart = false;
239
+ continue;
240
+ }
241
+ }
242
+ // Push current chunk (trimming trailing whitespace for display)
243
+ const trimmedChunk = currentChunk.trimEnd();
244
+ if (trimmedChunk || chunks.length === 0) {
245
+ chunks.push({
246
+ text: trimmedChunk,
247
+ startIndex: chunkStartIndex,
248
+ endIndex: chunkStartIndex + currentChunk.length,
249
+ });
250
+ }
251
+ // Start new line - skip leading whitespace
252
+ atLineStart = true;
253
+ if (token.isWhitespace) {
254
+ currentChunk = "";
255
+ currentWidth = 0;
256
+ chunkStartIndex = token.endIndex;
257
+ } else {
258
+ currentChunk = token.text;
259
+ currentWidth = tokenWidth;
260
+ chunkStartIndex = token.startIndex;
261
+ atLineStart = false;
262
+ }
263
+ } else {
264
+ // Add token to current chunk
265
+ currentChunk += token.text;
266
+ currentWidth += tokenWidth;
267
+ }
268
+ }
269
+
270
+ // Push final chunk
271
+ if (currentChunk) {
272
+ chunks.push({
273
+ text: currentChunk,
274
+ startIndex: chunkStartIndex,
275
+ endIndex: line.length,
276
+ });
277
+ }
278
+
279
+ return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }];
280
+ }
281
+
282
+ const DEFAULT_PAGE_SCROLL_LINES = 10;
283
+
284
+ interface EditorState {
285
+ lines: string[];
286
+ cursorLine: number;
287
+ cursorCol: number;
288
+ }
289
+
290
+ interface LayoutLine {
291
+ text: string;
292
+ hasCursor: boolean;
293
+ cursorPos?: number;
294
+ }
295
+
296
+ export interface EditorTheme {
297
+ borderColor: (str: string) => string;
298
+ selectList: SelectListTheme;
299
+ symbols: SymbolTheme;
300
+ editorPaddingX?: number;
301
+ /** Style function for inline hint/ghost text (dim text after cursor) */
302
+ hintStyle?: (text: string) => string;
303
+ }
304
+
305
+ export interface EditorTopBorder {
306
+ /** The status content (already styled) */
307
+ content: string;
308
+ /** Visible width of the content */
309
+ width: number;
310
+ }
311
+
312
+ interface HistoryEntry {
313
+ prompt: string;
314
+ }
315
+
316
+ interface HistoryStorage {
317
+ add(prompt: string, cwd?: string): Promise<void>;
318
+ getRecent(limit: number): HistoryEntry[];
319
+ }
320
+
321
+ type HistoryCursorAnchor = "start" | "end";
322
+
323
+ export class Editor implements Component, Focusable {
324
+ #state: EditorState = {
325
+ lines: [""],
326
+ cursorLine: 0,
327
+ cursorCol: 0,
328
+ };
329
+
330
+ /** Focusable interface - set by TUI when focus changes */
331
+ focused: boolean = false;
332
+
333
+ #theme: EditorTheme;
334
+ #useTerminalCursor = false;
335
+
336
+ /** When set, replaces the normal cursor glyph at end-of-text with this ANSI-styled string. */
337
+ cursorOverride: string | undefined;
338
+ /** Display width of the cursorOverride glyph (needed because override may contain ANSI escapes). */
339
+ cursorOverrideWidth: number | undefined;
340
+ #promptGutter: string | undefined;
341
+
342
+ // Store last layout width for cursor navigation
343
+ #lastLayoutWidth: number = 80;
344
+ #paddingXOverride: number | undefined;
345
+ #maxHeight?: number;
346
+ #scrollOffset: number = 0;
347
+
348
+ // Emacs-style kill ring
349
+ #killRing = new KillRing();
350
+ #lastAction: "kill" | "yank" | null = null;
351
+
352
+ // Character jump mode
353
+ #jumpMode: "forward" | "backward" | null = null;
354
+
355
+ // Preferred visual column for vertical cursor movement (sticky column)
356
+ #preferredVisualCol: number | null = null;
357
+
358
+ // Border color (can be changed dynamically)
359
+ borderColor: (str: string) => string;
360
+
361
+ // Autocomplete support
362
+ #autocompleteProvider?: AutocompleteProvider;
363
+ #autocompleteList?: SelectList;
364
+ #autocompleteState: "regular" | "force" | null = null;
365
+ #autocompletePrefix: string = "";
366
+ #autocompleteRequestId: number = 0;
367
+ #autocompleteMaxVisible: number = 5;
368
+ onAutocompleteUpdate?: () => void;
369
+
370
+ // Paste tracking for large pastes
371
+ #pastes: Map<number, string> = new Map();
372
+ #pasteCounter: number = 0;
373
+
374
+ // Bracketed paste mode buffering
375
+ #pasteHandler = new BracketedPasteHandler();
376
+
377
+ // Prompt history for up/down navigation
378
+ #history: string[] = [];
379
+ #historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
380
+ #historyStorage?: HistoryStorage;
381
+
382
+ // Undo stack for editor state changes
383
+ #undoStack: EditorState[] = [];
384
+ #suspendUndo = false;
385
+
386
+ // Debounce timer for autocomplete updates
387
+ #autocompleteTimeout?: NodeJS.Timeout;
388
+
389
+ onSubmit?: (text: string) => void;
390
+ onAltEnter?: (text: string) => void;
391
+ onChange?: (text: string) => void;
392
+ onAutocompleteCancel?: () => void;
393
+ disableSubmit: boolean = false;
394
+
395
+ // Custom top border (for status line integration)
396
+ #topBorderContent?: EditorTopBorder;
397
+ #borderVisible = true;
398
+
399
+ constructor(theme: EditorTheme) {
400
+ this.#theme = theme;
401
+ this.borderColor = theme.borderColor;
402
+ }
403
+
404
+ setAutocompleteProvider(provider: AutocompleteProvider): void {
405
+ this.#autocompleteProvider = provider;
406
+ }
407
+
408
+ /**
409
+ * Set custom content for the top border (e.g., status line).
410
+ * Pass undefined to use the default plain border.
411
+ */
412
+ setTopBorder(content: EditorTopBorder | undefined): void {
413
+ this.#topBorderContent = content;
414
+ }
415
+
416
+ /**
417
+ * Show or hide the editor border chrome.
418
+ */
419
+ setBorderVisible(borderVisible: boolean): void {
420
+ this.#borderVisible = borderVisible;
421
+ }
422
+
423
+ setPromptGutter(promptGutter: string | undefined): void {
424
+ this.#promptGutter = promptGutter;
425
+ }
426
+
427
+ /**
428
+ * Get the available width for top border content given a total terminal width.
429
+ * Accounts for the border characters and horizontal padding when visible.
430
+ */
431
+ getTopBorderAvailableWidth(terminalWidth: number): number {
432
+ const paddingX = this.#getEditorPaddingX();
433
+ const borderWidth = this.#getHorizontalChromeWidth(paddingX);
434
+ return Math.max(0, terminalWidth - borderWidth * 2);
435
+ }
436
+
437
+ /**
438
+ * Use the real terminal cursor instead of rendering a cursor glyph.
439
+ */
440
+ setUseTerminalCursor(useTerminalCursor: boolean): void {
441
+ this.#useTerminalCursor = useTerminalCursor;
442
+ }
443
+
444
+ getUseTerminalCursor(): boolean {
445
+ return this.#useTerminalCursor;
446
+ }
447
+
448
+ setMaxHeight(maxHeight: number | undefined): void {
449
+ if (this.#maxHeight === maxHeight) return;
450
+ this.#maxHeight = maxHeight;
451
+ // Don't reset scrollOffset — #updateScrollOffset will clamp it on next render
452
+ }
453
+
454
+ setPaddingX(paddingX: number): void {
455
+ this.#paddingXOverride = Math.max(0, paddingX);
456
+ }
457
+
458
+ getAutocompleteMaxVisible(): number {
459
+ return this.#autocompleteMaxVisible;
460
+ }
461
+
462
+ setAutocompleteMaxVisible(maxVisible: number): void {
463
+ const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
464
+ if (this.#autocompleteMaxVisible !== newMaxVisible) {
465
+ this.#autocompleteMaxVisible = newMaxVisible;
466
+ }
467
+ }
468
+
469
+ setHistoryStorage(storage: HistoryStorage): void {
470
+ this.#historyStorage = storage;
471
+ const recent = storage.getRecent(100);
472
+ this.#history = recent.map(entry => entry.prompt);
473
+ this.#historyIndex = -1;
474
+ }
475
+
476
+ /**
477
+ * Add a prompt to history for up/down arrow navigation.
478
+ * Called after successful submission.
479
+ */
480
+ addToHistory(text: string): void {
481
+ const trimmed = text.trim();
482
+ if (!trimmed) return;
483
+ // Don't add consecutive duplicates
484
+ if (this.#history.length > 0 && this.#history[0] === trimmed) return;
485
+ this.#history.unshift(trimmed);
486
+ // Limit history size
487
+ if (this.#history.length > 100) {
488
+ this.#history.pop();
489
+ }
490
+
491
+ const stor = this.#historyStorage;
492
+ if (stor) {
493
+ stor.add(trimmed, getProjectDir()).catch(error => {
494
+ logger.error("HistoryStorage add failed", { error: String(error) });
495
+ });
496
+ }
497
+ }
498
+
499
+ #isEditorEmpty(): boolean {
500
+ return this.#state.lines.length === 1 && this.#state.lines[0] === "";
501
+ }
502
+
503
+ #isOnFirstVisualLine(): boolean {
504
+ const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
505
+ const currentVisualLine = this.#findCurrentVisualLine(visualLines);
506
+ return currentVisualLine === 0;
507
+ }
508
+
509
+ #isOnLastVisualLine(): boolean {
510
+ const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
511
+ const currentVisualLine = this.#findCurrentVisualLine(visualLines);
512
+ return currentVisualLine === visualLines.length - 1;
513
+ }
514
+
515
+ #navigateHistory(direction: 1 | -1): void {
516
+ this.#resetKillSequence();
517
+ if (this.#history.length === 0) return;
518
+ const newIndex = this.#historyIndex - direction; // Up(-1) increases index, Down(1) decreases
519
+ if (newIndex < -1 || newIndex >= this.#history.length) return;
520
+ this.#historyIndex = newIndex;
521
+ if (this.#historyIndex === -1) {
522
+ // Returned to "current" state - clear editor
523
+ this.#setTextInternal("", "end");
524
+ } else {
525
+ const cursorAnchor: HistoryCursorAnchor = direction === -1 ? "start" : "end";
526
+ this.#setTextInternal(this.#history[this.#historyIndex] || "", cursorAnchor);
527
+ }
528
+ }
529
+ /** Internal setText that doesn't reset history state - used by navigateHistory */
530
+ #setTextInternal(text: string, cursorAnchor: HistoryCursorAnchor = "end"): void {
531
+ this.#undoStack.length = 0;
532
+ const lines = sanitizeLoadedText(text).split("\n");
533
+ this.#state.lines = lines.length === 0 ? [""] : lines;
534
+ if (cursorAnchor === "start") {
535
+ this.#state.cursorLine = 0;
536
+ this.#setCursorCol(0);
537
+ } else {
538
+ this.#state.cursorLine = this.#state.lines.length - 1;
539
+ this.#setCursorCol(this.#state.lines[this.#state.cursorLine]?.length || 0);
540
+ }
541
+ if (this.onChange) {
542
+ this.onChange(this.getText());
543
+ }
544
+ }
545
+
546
+ invalidate(): void {
547
+ // No cached state to invalidate currently
548
+ }
549
+
550
+ #getEditorPaddingX(): number {
551
+ const padding = this.#paddingXOverride ?? this.#theme.editorPaddingX ?? 2;
552
+ return Math.max(0, padding);
553
+ }
554
+
555
+ #getHorizontalChromeWidth(paddingX: number): number {
556
+ return this.#borderVisible ? paddingX + 1 : 0;
557
+ }
558
+
559
+ #getPromptGutterWidth(width: number, paddingX: number): number {
560
+ if (this.#borderVisible || !this.#promptGutter) return 0;
561
+ const chromeWidth = 2 * this.#getHorizontalChromeWidth(paddingX);
562
+ const availableWidth = Math.max(0, width - chromeWidth);
563
+ return Math.min(visibleWidth(this.#promptGutter), availableWidth);
564
+ }
565
+
566
+ #getPromptGutter(
567
+ width: number,
568
+ paddingX: number,
569
+ ): { firstLine: string; continuation: string; width: number } | undefined {
570
+ if (this.#borderVisible || !this.#promptGutter) return undefined;
571
+ const gutterWidth = this.#getPromptGutterWidth(width, paddingX);
572
+ if (gutterWidth === 0) return undefined;
573
+ return {
574
+ firstLine: sliceByColumn(this.#promptGutter, 0, gutterWidth, true),
575
+ continuation: padding(gutterWidth),
576
+ width: gutterWidth,
577
+ };
578
+ }
579
+
580
+ #getContentWidth(width: number, paddingX: number): number {
581
+ const chromeWidth = 2 * this.#getHorizontalChromeWidth(paddingX);
582
+ return Math.max(0, width - chromeWidth - this.#getPromptGutterWidth(width, paddingX));
583
+ }
584
+
585
+ #getLayoutWidth(width: number, paddingX: number): number {
586
+ const contentWidth = this.#getContentWidth(width, paddingX);
587
+ const cursorReserve = this.#borderVisible && paddingX === 0 ? 1 : 0;
588
+ // Keep cursor/scroll layout addressable even when a borderless prompt gutter consumes every visible column.
589
+ return Math.max(1, contentWidth - cursorReserve);
590
+ }
591
+
592
+ #getVisibleContentHeight(contentLines: number): number {
593
+ if (this.#maxHeight === undefined) return contentLines;
594
+ const verticalChrome = this.#borderVisible ? 2 : 0;
595
+ return Math.max(1, this.#maxHeight - verticalChrome);
596
+ }
597
+
598
+ #getStyledInputCursor(): { text: string; width: number } {
599
+ const cursorChar = this.#theme.symbols.inputCursor;
600
+ return { text: `\x1b[5m${cursorChar}\x1b[0m`, width: visibleWidth(cursorChar) };
601
+ }
602
+
603
+ #renderEndOfLineCursorAtWidthLimit(
604
+ before: string,
605
+ marker: string,
606
+ maxWidth: number,
607
+ replacement?: { text: string; width: number },
608
+ ): { text: string; width: number } {
609
+ const beforeGraphemes = [...segmenter.segment(before)];
610
+ const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment;
611
+ const lastGraphemeWidth = lastGrapheme ? visibleWidth(lastGrapheme) : 0;
612
+ const builtInCursor = this.#getStyledInputCursor();
613
+ const fallbackReplacement = lastGrapheme
614
+ ? { text: `\x1b[7m${lastGrapheme}\x1b[0m`, width: lastGraphemeWidth }
615
+ : builtInCursor;
616
+ const clampReplacement = (candidate: { text: string; width: number }): { text: string; width: number } => {
617
+ let text = sliceByColumn(candidate.text, 0, maxWidth, true);
618
+ let width = visibleWidth(text);
619
+ if (width > maxWidth) {
620
+ text = "";
621
+ width = 0;
622
+ }
623
+ return { text, width };
624
+ };
625
+
626
+ let clampedReplacement = clampReplacement(replacement ?? fallbackReplacement);
627
+ if (replacement && clampedReplacement.width === 0) {
628
+ // A custom override that cannot fit at all should first fall back to the highlighted tail.
629
+ clampedReplacement = clampReplacement(fallbackReplacement);
630
+ }
631
+ if (lastGrapheme && clampedReplacement.width === 0) {
632
+ // If even the highlighted trailing grapheme cannot fit, show the built-in single-column cursor.
633
+ clampedReplacement = clampReplacement(builtInCursor);
634
+ }
635
+
636
+ const replacedSpanWidth = Math.min(maxWidth, Math.max(lastGraphemeWidth, clampedReplacement.width));
637
+ const prefixWidth = Math.max(0, maxWidth - replacedSpanWidth);
638
+ const beforePrefix = sliceByColumn(before, 0, prefixWidth, true);
639
+ const replacementPad = padding(Math.max(0, replacedSpanWidth - clampedReplacement.width));
640
+ return {
641
+ text: `${beforePrefix}${replacementPad}${clampedReplacement.text}${marker}`,
642
+ width: visibleWidth(beforePrefix) + replacedSpanWidth,
643
+ };
644
+ }
645
+
646
+ #renderTerminalCursorMarker(text: string, marker: string, maxWidth: number): string {
647
+ if (!marker) return text;
648
+ if (visibleWidth(text) < maxWidth) {
649
+ return text + marker;
650
+ }
651
+
652
+ let insertAt = text.length;
653
+ let offset = 0;
654
+ for (const seg of segmenter.segment(text)) {
655
+ if (visibleWidth(seg.segment) > 0) {
656
+ insertAt = offset;
657
+ }
658
+ offset += seg.segment.length;
659
+ }
660
+
661
+ return `${text.slice(0, insertAt)}${marker}${text.slice(insertAt)}`;
662
+ }
663
+
664
+ #getPageScrollStep(totalVisualLines: number): number {
665
+ const visibleHeight =
666
+ this.#maxHeight === undefined ? DEFAULT_PAGE_SCROLL_LINES : this.#getVisibleContentHeight(totalVisualLines);
667
+ return Math.max(1, visibleHeight - 1);
668
+ }
669
+
670
+ #updateScrollOffset(layoutWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void {
671
+ if (layoutLines.length <= visibleHeight) {
672
+ this.#scrollOffset = 0;
673
+ return;
674
+ }
675
+
676
+ const visualLines = this.#buildVisualLineMap(layoutWidth);
677
+ const cursorLine = this.#findCurrentVisualLine(visualLines);
678
+ if (cursorLine < this.#scrollOffset) {
679
+ this.#scrollOffset = cursorLine;
680
+ } else if (cursorLine >= this.#scrollOffset + visibleHeight) {
681
+ this.#scrollOffset = cursorLine - visibleHeight + 1;
682
+ }
683
+
684
+ const maxOffset = Math.max(0, layoutLines.length - visibleHeight);
685
+ this.#scrollOffset = Math.min(this.#scrollOffset, maxOffset);
686
+ }
687
+
688
+ render(width: number): string[] {
689
+ const paddingX = this.#getEditorPaddingX();
690
+ const borderVisible = this.#borderVisible;
691
+ const promptGutter = this.#getPromptGutter(width, paddingX);
692
+ const contentAreaWidth = this.#getContentWidth(width, paddingX);
693
+ const layoutWidth = this.#getLayoutWidth(width, paddingX);
694
+ this.#lastLayoutWidth = layoutWidth;
695
+
696
+ // Box-drawing characters for rounded corners
697
+ const box = this.#theme.symbols.boxRound;
698
+ const borderWidth = this.#getHorizontalChromeWidth(paddingX);
699
+ const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
700
+ const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
701
+ const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`);
702
+ const horizontal = this.borderColor(box.horizontal);
703
+
704
+ // Layout the text
705
+ const layoutLines = this.#layoutText(layoutWidth);
706
+ const visibleContentHeight = this.#getVisibleContentHeight(layoutLines.length);
707
+ this.#updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight);
708
+ const visibleLayoutLines = layoutLines.slice(this.#scrollOffset, this.#scrollOffset + visibleContentHeight);
709
+
710
+ const result: string[] = [];
711
+
712
+ if (borderVisible) {
713
+ // Render top border: ╭─ [status content] ────────────────╮
714
+ const topFillWidth = Math.max(0, width - borderWidth * 2);
715
+ if (this.#topBorderContent) {
716
+ const { content, width: statusWidth } = this.#topBorderContent;
717
+ if (statusWidth <= topFillWidth) {
718
+ // Status fits - add fill after it
719
+ const fillWidth = topFillWidth - statusWidth;
720
+ result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
721
+ } else {
722
+ // Status too long - truncate it
723
+ const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1));
724
+ const truncatedWidth = visibleWidth(truncated);
725
+ const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
726
+ result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
727
+ }
728
+ } else {
729
+ result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
730
+ }
731
+ }
732
+
733
+ // Render each layout line
734
+ // Emit hardware cursor marker only when focused and not showing autocomplete
735
+ const emitCursorMarker = this.focused && !this.#autocompleteState;
736
+ const lineContentWidth = contentAreaWidth;
737
+
738
+ // Compute inline hint text (dim ghost text after cursor)
739
+ const inlineHint = this.#getInlineHint();
740
+ const hintStyle = this.#theme.hintStyle ?? ((t: string) => `\x1b[2m${t}\x1b[0m`);
741
+
742
+ for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) {
743
+ const layoutLine = visibleLayoutLines[visibleIndex]!;
744
+ let displayText = layoutLine.text;
745
+ let displayWidth = visibleWidth(layoutLine.text);
746
+ let cursorInPadding = false;
747
+ const showPromptGutter = promptGutter !== undefined && visibleIndex === 0;
748
+ const gutterText =
749
+ promptGutter === undefined ? "" : showPromptGutter ? promptGutter.firstLine : promptGutter.continuation;
750
+
751
+ // Add cursor if this line has it
752
+ const hasCursor = layoutLine.hasCursor && layoutLine.cursorPos !== undefined;
753
+ const marker = emitCursorMarker ? CURSOR_MARKER : "";
754
+
755
+ if (!borderVisible && displayWidth > lineContentWidth) {
756
+ displayText = sliceByColumn(displayText, 0, lineContentWidth, true);
757
+ displayWidth = visibleWidth(displayText);
758
+ }
759
+
760
+ if (!borderVisible && lineContentWidth === 0) {
761
+ if (hasCursor && !this.#useTerminalCursor) {
762
+ const zeroWidthCursorBudget = visibleWidth(gutterText);
763
+ const zeroWidthCursorReplacement = this.cursorOverride
764
+ ? { text: this.cursorOverride, width: this.cursorOverrideWidth ?? 1 }
765
+ : this.#getStyledInputCursor();
766
+ if (showPromptGutter && zeroWidthCursorBudget > 0) {
767
+ // Keep the leading prompt glyph visible when the gutter consumes the whole row.
768
+ const promptGlyph = [...segmenter.segment(gutterText)][0]?.segment ?? "";
769
+ const promptGlyphWidth = visibleWidth(promptGlyph);
770
+ const remainingCursorWidth = Math.max(0, zeroWidthCursorBudget - promptGlyphWidth);
771
+ if (remainingCursorWidth === 0) {
772
+ result.push(`\x1b[7m${promptGlyph}\x1b[0m${marker}`);
773
+ } else {
774
+ const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(
775
+ "",
776
+ marker,
777
+ remainingCursorWidth,
778
+ zeroWidthCursorReplacement,
779
+ );
780
+ result.push(`${promptGlyph}${widthLimitedCursor.text}`);
781
+ }
782
+ } else {
783
+ const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(
784
+ gutterText,
785
+ marker,
786
+ zeroWidthCursorBudget,
787
+ zeroWidthCursorReplacement,
788
+ );
789
+ result.push(widthLimitedCursor.text);
790
+ }
791
+ } else if (hasCursor && this.#useTerminalCursor) {
792
+ result.push(this.#renderTerminalCursorMarker(gutterText, marker, visibleWidth(gutterText)));
793
+ } else {
794
+ result.push(gutterText + (hasCursor ? marker : ""));
795
+ }
796
+ continue;
797
+ }
798
+
799
+ if (hasCursor && this.#useTerminalCursor) {
800
+ if (marker) {
801
+ const before = displayText.slice(0, layoutLine.cursorPos);
802
+ const after = displayText.slice(layoutLine.cursorPos);
803
+ if (after.length === 0 && inlineHint) {
804
+ const hintText = hintStyle(truncateToWidth(inlineHint, Math.max(0, lineContentWidth - displayWidth)));
805
+ displayText = before + marker + hintText;
806
+ displayWidth += visibleWidth(inlineHint);
807
+ } else if (after.length === 0 && !borderVisible && displayWidth >= lineContentWidth) {
808
+ displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth);
809
+ } else {
810
+ displayText = before + marker + after;
811
+ }
812
+ }
813
+ } else if (hasCursor && !this.#useTerminalCursor) {
814
+ const before = displayText.slice(0, layoutLine.cursorPos);
815
+ const after = displayText.slice(layoutLine.cursorPos);
816
+
817
+ if (after.length > 0) {
818
+ // Cursor is on a character (grapheme) - replace it with highlighted version
819
+ // Get the first grapheme from 'after'
820
+ const afterGraphemes = [...segmenter.segment(after)];
821
+ const firstGrapheme = afterGraphemes[0]?.segment || "";
822
+ const restAfter = after.slice(firstGrapheme.length);
823
+ const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
824
+ displayText = before + marker + cursor + restAfter;
825
+ // displayWidth stays the same - we're replacing, not adding
826
+ } else if (this.cursorOverride) {
827
+ // Cursor override replaces the normal end-of-text cursor glyph
828
+ const overrideWidth = this.cursorOverrideWidth ?? 1;
829
+ if (!borderVisible && displayWidth + overrideWidth > lineContentWidth) {
830
+ // Borderless editors have no spare padding cell for an end-of-line cursor glyph.
831
+ // Preserve cursorOverride by replacing the tail of the line with it.
832
+ const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth, {
833
+ text: this.cursorOverride,
834
+ width: overrideWidth,
835
+ });
836
+ displayText = widthLimitedCursor.text;
837
+ displayWidth = widthLimitedCursor.width;
838
+ } else if (inlineHint) {
839
+ const availWidth = Math.max(0, lineContentWidth - displayWidth - overrideWidth);
840
+ const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
841
+ displayText = before + marker + this.cursorOverride + hintText;
842
+ displayWidth += overrideWidth + Math.min(visibleWidth(inlineHint), availWidth);
843
+ } else {
844
+ displayText = before + marker + this.cursorOverride;
845
+ displayWidth += overrideWidth;
846
+ }
847
+ } else {
848
+ // Cursor is at the end - add thin cursor glyph
849
+ const { text: cursor, width: cursorWidth } = this.#getStyledInputCursor();
850
+ if (!borderVisible && displayWidth + cursorWidth > lineContentWidth) {
851
+ // Borderless editors have no spare padding cell for an end-of-line cursor glyph.
852
+ // Highlight the last grapheme so the cursor stays visible without consuming width.
853
+ const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth);
854
+ displayText = widthLimitedCursor.text;
855
+ displayWidth = widthLimitedCursor.width;
856
+ } else if (inlineHint) {
857
+ const availWidth = Math.max(0, lineContentWidth - displayWidth - cursorWidth);
858
+ const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
859
+ displayText = before + marker + cursor + hintText;
860
+ displayWidth += cursorWidth + Math.min(visibleWidth(inlineHint), availWidth);
861
+ } else {
862
+ displayText = before + marker + cursor;
863
+ displayWidth += cursorWidth;
864
+ }
865
+ if (displayWidth > lineContentWidth && paddingX > 0) {
866
+ cursorInPadding = true;
867
+ }
868
+ }
869
+ }
870
+
871
+ const linePad = padding(Math.max(0, lineContentWidth - displayWidth));
872
+
873
+ if (!borderVisible) {
874
+ result.push(gutterText + displayText + linePad);
875
+ continue;
876
+ }
877
+
878
+ // All lines have consistent borders based on padding
879
+ const isLastLine = visibleIndex === visibleLayoutLines.length - 1;
880
+ const rightPaddingWidth = Math.max(0, paddingX - (cursorInPadding ? 1 : 0));
881
+ if (isLastLine) {
882
+ const bottomRightPadding = Math.max(0, paddingX - 1 - (cursorInPadding ? 1 : 0));
883
+ const bottomRightAdjusted = this.borderColor(
884
+ `${padding(bottomRightPadding)}${box.horizontal}${box.bottomRight}`,
885
+ );
886
+ result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`);
887
+ } else {
888
+ const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
889
+ const rightBorder = this.borderColor(`${padding(rightPaddingWidth)}${box.vertical}`);
890
+ result.push(leftBorder + displayText + linePad + rightBorder);
891
+ }
892
+ }
893
+
894
+ // Add autocomplete list if active
895
+ if (this.#autocompleteState && this.#autocompleteList) {
896
+ const autocompleteResult = this.#autocompleteList.render(width);
897
+ result.push(...autocompleteResult);
898
+ }
899
+
900
+ return result;
901
+ }
902
+
903
+ handleInput(data: string): void {
904
+ const kb = getKeybindings();
905
+
906
+ // Handle character jump mode (awaiting next character to jump to)
907
+ if (this.#jumpMode !== null) {
908
+ // Cancel if the hotkey is pressed again
909
+ if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
910
+ this.#jumpMode = null;
911
+ return;
912
+ }
913
+
914
+ const printableText = extractPrintableText(data);
915
+ if (printableText) {
916
+ const direction = this.#jumpMode;
917
+ this.#jumpMode = null;
918
+ this.#jumpToChar(printableText, direction);
919
+ return;
920
+ }
921
+
922
+ // Control character - cancel and fall through to normal handling
923
+ this.#jumpMode = null;
924
+ }
925
+
926
+ // Handle bracketed paste mode
927
+ const paste = this.#pasteHandler.process(data);
928
+ if (paste.handled) {
929
+ if (paste.pasteContent !== undefined) {
930
+ this.#handlePaste(paste.pasteContent);
931
+ if (paste.remaining.length > 0) {
932
+ this.handleInput(paste.remaining);
933
+ }
934
+ }
935
+ return;
936
+ }
937
+
938
+ // Handle special key combinations first
939
+
940
+ // Ctrl+C is reserved by parent components for app-level handling.
941
+ // Do not consume arbitrary user-bound "copy" keys here, since the editor
942
+ // has no copy implementation and would make those keys disappear.
943
+ if (matchesKey(data, "ctrl+c")) {
944
+ return;
945
+ }
946
+
947
+ // Undo
948
+ if (kb.matches(data, "tui.editor.undo")) {
949
+ this.#applyUndo();
950
+ return;
951
+ }
952
+
953
+ // Handle autocomplete special keys first (but don't block other input)
954
+ if (this.#autocompleteState && this.#autocompleteList) {
955
+ // Escape - cancel autocomplete
956
+ if (kb.matches(data, "tui.select.cancel")) {
957
+ this.#cancelAutocomplete(true);
958
+ return;
959
+ }
960
+ // Let the autocomplete list handle navigation and selection
961
+ else if (
962
+ kb.matches(data, "tui.select.up") ||
963
+ kb.matches(data, "tui.select.down") ||
964
+ kb.matches(data, "tui.select.pageUp") ||
965
+ kb.matches(data, "tui.select.pageDown") ||
966
+ kb.matches(data, "tui.input.submit") ||
967
+ data === "\n" ||
968
+ kb.matches(data, "tui.input.tab")
969
+ ) {
970
+ // Only pass navigation keys to the list, not Enter/Tab (we handle those directly)
971
+ if (
972
+ kb.matches(data, "tui.select.up") ||
973
+ kb.matches(data, "tui.select.down") ||
974
+ kb.matches(data, "tui.select.pageUp") ||
975
+ kb.matches(data, "tui.select.pageDown")
976
+ ) {
977
+ this.#autocompleteList.handleInput(data);
978
+ this.onAutocompleteUpdate?.();
979
+ return;
980
+ }
981
+
982
+ // If Tab was pressed, always apply the selection
983
+ if (kb.matches(data, "tui.input.tab")) {
984
+ const selected = this.#autocompleteList.getSelectedItem();
985
+ if (selected && this.#autocompleteProvider) {
986
+ const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
987
+ const result = this.#autocompleteProvider.applyCompletion(
988
+ this.#state.lines,
989
+ this.#state.cursorLine,
990
+ this.#state.cursorCol,
991
+ selected,
992
+ this.#autocompletePrefix,
993
+ );
994
+
995
+ this.#state.lines = result.lines;
996
+ this.#state.cursorLine = result.cursorLine;
997
+ this.#setCursorCol(result.cursorCol);
998
+
999
+ this.#cancelAutocomplete();
1000
+ this.onAutocompleteUpdate?.();
1001
+
1002
+ if (this.onChange) {
1003
+ this.onChange(this.getText());
1004
+ }
1005
+
1006
+ result.onApplied?.();
1007
+
1008
+ if (shouldChainSlashCommandAutocomplete && this.#isCompletedSlashCommandAtCursor()) {
1009
+ void this.#tryTriggerAutocomplete();
1010
+ }
1011
+ }
1012
+ return;
1013
+ }
1014
+
1015
+ // If Enter was pressed on a slash command, apply completion and submit
1016
+ if ((kb.matches(data, "tui.input.submit") || data === "\n") && this.#autocompletePrefix.startsWith("/")) {
1017
+ // Check for stale autocomplete state due to debounce
1018
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1019
+ const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1020
+ if (currentTextBeforeCursor !== this.#autocompletePrefix) {
1021
+ // Autocomplete is stale - cancel and fall through to normal submission
1022
+ this.#cancelAutocomplete();
1023
+ } else {
1024
+ const selected = this.#autocompleteList.getSelectedItem();
1025
+ if (selected && this.#autocompleteProvider) {
1026
+ const result = this.#autocompleteProvider.applyCompletion(
1027
+ this.#state.lines,
1028
+ this.#state.cursorLine,
1029
+ this.#state.cursorCol,
1030
+ selected,
1031
+ this.#autocompletePrefix,
1032
+ );
1033
+
1034
+ this.#state.lines = result.lines;
1035
+ this.#state.cursorLine = result.cursorLine;
1036
+ this.#setCursorCol(result.cursorCol);
1037
+ result.onApplied?.();
1038
+ }
1039
+ this.#cancelAutocomplete();
1040
+ }
1041
+ // Don't return - fall through to submission logic
1042
+ }
1043
+ // If Enter was pressed on a file path, apply completion
1044
+ else if (kb.matches(data, "tui.input.submit") || data === "\n") {
1045
+ const selected = this.#autocompleteList.getSelectedItem();
1046
+ if (selected && this.#autocompleteProvider) {
1047
+ const result = this.#autocompleteProvider.applyCompletion(
1048
+ this.#state.lines,
1049
+ this.#state.cursorLine,
1050
+ this.#state.cursorCol,
1051
+ selected,
1052
+ this.#autocompletePrefix,
1053
+ );
1054
+
1055
+ this.#state.lines = result.lines;
1056
+ this.#state.cursorLine = result.cursorLine;
1057
+ this.#setCursorCol(result.cursorCol);
1058
+
1059
+ this.#cancelAutocomplete();
1060
+ this.onAutocompleteUpdate?.();
1061
+
1062
+ if (this.onChange) {
1063
+ this.onChange(this.getText());
1064
+ }
1065
+
1066
+ result.onApplied?.();
1067
+ }
1068
+ return;
1069
+ }
1070
+ }
1071
+ // For other keys (like regular typing), DON'T return here
1072
+ // Let them fall through to normal character handling
1073
+ }
1074
+
1075
+ // Tab key - context-aware completion (but not when already autocompleting)
1076
+ if (kb.matches(data, "tui.input.tab") && !this.#autocompleteState) {
1077
+ this.#handleTabCompletion();
1078
+ return;
1079
+ }
1080
+
1081
+ // Continue with rest of input handling
1082
+ // Ctrl+K - Delete to end of line
1083
+ if (matchesKey(data, "ctrl+k")) {
1084
+ this.#deleteToEndOfLine();
1085
+ }
1086
+ // Ctrl+U - Delete to start of line
1087
+ else if (matchesKey(data, "ctrl+u")) {
1088
+ this.#deleteToStartOfLine();
1089
+ }
1090
+ // Ctrl+W - Delete word backwards
1091
+ else if (matchesKey(data, "ctrl+w")) {
1092
+ this.#deleteWordBackwards();
1093
+ }
1094
+ // Option/Alt+Backspace - Delete word backwards
1095
+ else if (matchesKey(data, "alt+backspace")) {
1096
+ this.#deleteWordBackwards();
1097
+ }
1098
+ // Option/Alt+D - Delete word forwards
1099
+ else if (matchesKey(data, "alt+d") || matchesKey(data, "alt+delete")) {
1100
+ this.#deleteWordForwards();
1101
+ }
1102
+ // Ctrl+Y - Yank from kill ring
1103
+ else if (matchesKey(data, "ctrl+y")) {
1104
+ this.#yankFromKillRing();
1105
+ }
1106
+ // Alt+Y - Yank-pop (cycle kill ring)
1107
+ else if (matchesKey(data, "alt+y")) {
1108
+ this.#yankPop();
1109
+ }
1110
+ // Ctrl+A - Move to start of line
1111
+ else if (matchesKey(data, "ctrl+a")) {
1112
+ this.#moveToLineStart();
1113
+ }
1114
+ // Ctrl+E - Move to end of line
1115
+ else if (matchesKey(data, "ctrl+e")) {
1116
+ this.#moveToLineEnd();
1117
+ }
1118
+ // Alt+Enter - special handler if callback exists, otherwise new line
1119
+ else if (matchesKey(data, "alt+enter")) {
1120
+ if (this.onAltEnter) {
1121
+ this.onAltEnter(this.getText());
1122
+ } else {
1123
+ this.#addNewLine();
1124
+ }
1125
+ }
1126
+ // New line
1127
+ else if (
1128
+ (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
1129
+ matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
1130
+ data === "\x1b\r" || // Option+Enter in some terminals (legacy)
1131
+ data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
1132
+ kb.matches(data, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
1133
+ (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
1134
+ (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
1135
+ ) {
1136
+ if (this.#shouldSubmitOnBackslashEnter(data, kb)) {
1137
+ this.#handleBackspace();
1138
+ this.#submitValue();
1139
+ return;
1140
+ }
1141
+ this.#addNewLine();
1142
+ }
1143
+ // Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
1144
+ else if (kb.matches(data, "tui.input.submit") || data === "\n") {
1145
+ // If submit is disabled, do nothing
1146
+ if (this.disableSubmit) {
1147
+ return;
1148
+ }
1149
+
1150
+ // Synchronous slash command completion for the race condition where
1151
+ // async autocomplete hasn't resolved yet (user types /q quickly + Enter).
1152
+ // Match the existing selected-item behavior when autocomplete IS showing.
1153
+ if (!this.#autocompleteState) {
1154
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1155
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1156
+ if (
1157
+ textBeforeCursor.startsWith("/") &&
1158
+ this.#isInSubmittedSlashCommandContext() &&
1159
+ this.#autocompleteProvider?.trySyncSlashCompletion
1160
+ ) {
1161
+ const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
1162
+ if (syncResult && syncResult.items.length > 0) {
1163
+ // Invalidate any pending async autocomplete so its stale results are discarded
1164
+ this.#autocompleteRequestId += 1;
1165
+ // Apply the best match and submit the completed command
1166
+ const selected = syncResult.items[0]!;
1167
+ const result = this.#autocompleteProvider.applyCompletion(
1168
+ this.#state.lines,
1169
+ this.#state.cursorLine,
1170
+ this.#state.cursorCol,
1171
+ selected,
1172
+ syncResult.prefix,
1173
+ );
1174
+ this.#state.lines = result.lines;
1175
+ this.#state.cursorLine = result.cursorLine;
1176
+ this.#setCursorCol(result.cursorCol);
1177
+ result.onApplied?.();
1178
+ }
1179
+ }
1180
+ }
1181
+
1182
+ this.#submitValue();
1183
+ }
1184
+ // Backspace (including Shift+Backspace)
1185
+ else if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
1186
+ this.#handleBackspace();
1187
+ }
1188
+ // Line navigation shortcuts (Home/End keys)
1189
+ else if (kb.matches(data, "tui.editor.cursorLineStart")) {
1190
+ this.#moveToLineStart();
1191
+ } else if (kb.matches(data, "tui.editor.cursorLineEnd")) {
1192
+ this.#moveToLineEnd();
1193
+ }
1194
+ // Page navigation (PageUp/PageDown)
1195
+ else if (kb.matches(data, "tui.editor.pageUp")) {
1196
+ if (this.#isEditorEmpty()) {
1197
+ this.#navigateHistory(-1);
1198
+ } else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) {
1199
+ this.#navigateHistory(-1);
1200
+ } else {
1201
+ this.#pageScroll(-1);
1202
+ }
1203
+ } else if (kb.matches(data, "tui.editor.pageDown")) {
1204
+ if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
1205
+ this.#navigateHistory(1);
1206
+ } else {
1207
+ this.#pageScroll(1);
1208
+ }
1209
+ }
1210
+ // Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
1211
+ else if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
1212
+ this.#handleForwardDelete();
1213
+ }
1214
+ // Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
1215
+ else if (kb.matches(data, "tui.editor.cursorWordLeft")) {
1216
+ // Word left
1217
+ this.#resetKillSequence();
1218
+ this.#moveWordBackwards();
1219
+ } else if (kb.matches(data, "tui.editor.cursorWordRight")) {
1220
+ // Word right
1221
+ this.#resetKillSequence();
1222
+ this.#moveWordForwards();
1223
+ }
1224
+ // Arrow keys
1225
+ else if (kb.matches(data, "tui.editor.cursorUp")) {
1226
+ // Up - history navigation or cursor movement
1227
+ if (this.#isEditorEmpty()) {
1228
+ this.#navigateHistory(-1); // Start browsing history
1229
+ } else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) {
1230
+ this.#navigateHistory(-1); // Navigate to older history entry
1231
+ } else if (this.#isOnFirstVisualLine()) {
1232
+ // Already at top - jump to start of line
1233
+ this.#moveToLineStart();
1234
+ } else {
1235
+ this.#moveCursor(-1, 0); // Cursor movement (within text or history entry)
1236
+ }
1237
+ } else if (kb.matches(data, "tui.editor.cursorDown")) {
1238
+ // Down - history navigation or cursor movement
1239
+ if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
1240
+ this.#navigateHistory(1); // Navigate to newer history entry or clear
1241
+ } else if (this.#isOnLastVisualLine()) {
1242
+ // Already at bottom - jump to end of line
1243
+ this.#moveToLineEnd();
1244
+ } else {
1245
+ this.#moveCursor(1, 0); // Cursor movement (within text or history entry)
1246
+ }
1247
+ } else if (kb.matches(data, "tui.editor.cursorRight")) {
1248
+ // Right
1249
+ this.#moveCursor(0, 1);
1250
+ } else if (kb.matches(data, "tui.editor.cursorLeft")) {
1251
+ // Left
1252
+ this.#moveCursor(0, -1);
1253
+ }
1254
+ // Shift+Space - insert regular space (Kitty protocol sends escape sequence)
1255
+ else if (matchesKey(data, "shift+space")) {
1256
+ this.#insertCharacter(" ");
1257
+ }
1258
+ // Character jump mode triggers
1259
+ else if (kb.matches(data, "tui.editor.jumpForward")) {
1260
+ this.#jumpMode = "forward";
1261
+ } else if (kb.matches(data, "tui.editor.jumpBackward")) {
1262
+ this.#jumpMode = "backward";
1263
+ }
1264
+ // Printable keystrokes, including Kitty CSI-u text-producing sequences.
1265
+ else {
1266
+ const printableText = extractPrintableText(data);
1267
+ if (printableText) {
1268
+ this.#insertCharacter(printableText);
1269
+ }
1270
+ }
1271
+ }
1272
+
1273
+ #layoutText(contentWidth: number): LayoutLine[] {
1274
+ const layoutLines: LayoutLine[] = [];
1275
+
1276
+ if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
1277
+ // Empty editor
1278
+ layoutLines.push({
1279
+ text: "",
1280
+ hasCursor: true,
1281
+ cursorPos: 0,
1282
+ });
1283
+ return layoutLines;
1284
+ }
1285
+
1286
+ // Process each logical line
1287
+ for (let i = 0; i < this.#state.lines.length; i++) {
1288
+ const line = this.#state.lines[i] || "";
1289
+ const isCurrentLine = i === this.#state.cursorLine;
1290
+ const lineVisibleWidth = visibleWidth(line);
1291
+
1292
+ if (lineVisibleWidth <= contentWidth) {
1293
+ // Line fits in one layout line
1294
+ if (isCurrentLine) {
1295
+ layoutLines.push({
1296
+ text: line,
1297
+ hasCursor: true,
1298
+ cursorPos: this.#state.cursorCol,
1299
+ });
1300
+ } else {
1301
+ layoutLines.push({
1302
+ text: line,
1303
+ hasCursor: false,
1304
+ });
1305
+ }
1306
+ } else {
1307
+ // Line needs wrapping - use word-aware wrapping
1308
+ const chunks = wordWrapLine(line, contentWidth);
1309
+
1310
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1311
+ const chunk = chunks[chunkIndex];
1312
+ if (!chunk) continue;
1313
+
1314
+ const cursorPos = this.#state.cursorCol;
1315
+ const isLastChunk = chunkIndex === chunks.length - 1;
1316
+
1317
+ // Determine if cursor is in this chunk
1318
+ // For word-wrapped chunks, we need to handle the case where
1319
+ // cursor might be in trimmed whitespace at end of chunk
1320
+ let hasCursorInChunk = false;
1321
+ let adjustedCursorPos = 0;
1322
+
1323
+ if (isCurrentLine) {
1324
+ if (isLastChunk) {
1325
+ // Last chunk: cursor belongs here if >= startIndex
1326
+ hasCursorInChunk = cursorPos >= chunk.startIndex;
1327
+ adjustedCursorPos = cursorPos - chunk.startIndex;
1328
+ } else {
1329
+ // Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
1330
+ // But we need to handle the visual position in the trimmed text
1331
+ hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
1332
+ if (hasCursorInChunk) {
1333
+ adjustedCursorPos = cursorPos - chunk.startIndex;
1334
+ // Clamp to text length (in case cursor was in trimmed whitespace)
1335
+ if (adjustedCursorPos > chunk.text.length) {
1336
+ adjustedCursorPos = chunk.text.length;
1337
+ }
1338
+ }
1339
+ }
1340
+ }
1341
+
1342
+ if (hasCursorInChunk) {
1343
+ layoutLines.push({
1344
+ text: chunk.text,
1345
+ hasCursor: true,
1346
+ cursorPos: adjustedCursorPos,
1347
+ });
1348
+ } else {
1349
+ layoutLines.push({
1350
+ text: chunk.text,
1351
+ hasCursor: false,
1352
+ });
1353
+ }
1354
+ }
1355
+ }
1356
+ }
1357
+
1358
+ return layoutLines;
1359
+ }
1360
+
1361
+ getText(): string {
1362
+ return this.#state.lines.join("\n");
1363
+ }
1364
+
1365
+ #expandPasteMarkers(text: string): string {
1366
+ let result = text;
1367
+ for (const [pasteId, pasteContent] of this.#pastes) {
1368
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
1369
+ result = result.replace(markerRegex, () => pasteContent);
1370
+ }
1371
+ return result;
1372
+ }
1373
+
1374
+ /**
1375
+ * Get text with paste markers expanded to their actual content.
1376
+ * Use this when you need the full content (e.g., for external editor).
1377
+ */
1378
+ getExpandedText(): string {
1379
+ return this.#expandPasteMarkers(this.#state.lines.join("\n"));
1380
+ }
1381
+
1382
+ getLines(): string[] {
1383
+ return [...this.#state.lines];
1384
+ }
1385
+
1386
+ getCursor(): { line: number; col: number } {
1387
+ return { line: this.#state.cursorLine, col: this.#state.cursorCol };
1388
+ }
1389
+
1390
+ moveToLineStart(): void {
1391
+ this.#moveToLineStart();
1392
+ }
1393
+
1394
+ moveToLineEnd(): void {
1395
+ this.#moveToLineEnd();
1396
+ }
1397
+
1398
+ moveToMessageStart(): void {
1399
+ this.#moveToMessageStart();
1400
+ }
1401
+
1402
+ moveToMessageEnd(): void {
1403
+ this.#moveToMessageEnd();
1404
+ }
1405
+
1406
+ /**
1407
+ * Undo the last meaningful edit while ignoring transient text that is still present at the cursor.
1408
+ * Used for command-like autocomplete actions whose typed trigger should not count as the edit being undone.
1409
+ */
1410
+ undoPastTransientText(transientText: string): void {
1411
+ if (transientText.length === 0) {
1412
+ this.#applyUndo();
1413
+ return;
1414
+ }
1415
+
1416
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1417
+ const transientStartCol = this.#state.cursorCol - transientText.length;
1418
+ if (transientStartCol < 0 || currentLine.slice(transientStartCol, this.#state.cursorCol) !== transientText) {
1419
+ this.#applyUndo();
1420
+ return;
1421
+ }
1422
+
1423
+ const beforeTransient = currentLine.slice(0, transientStartCol);
1424
+ const afterTransient = currentLine.slice(this.#state.cursorCol);
1425
+ this.#historyIndex = -1;
1426
+ this.#resetKillSequence();
1427
+ this.#preferredVisualCol = null;
1428
+ this.#state.lines[this.#state.cursorLine] = beforeTransient + afterTransient;
1429
+ this.#setCursorCol(transientStartCol);
1430
+
1431
+ while (true) {
1432
+ const snapshot = this.#undoStack.at(-1);
1433
+ if (
1434
+ !snapshot ||
1435
+ !this.#matchesTransientUndoSnapshot(
1436
+ snapshot,
1437
+ transientText,
1438
+ transientStartCol,
1439
+ beforeTransient,
1440
+ afterTransient,
1441
+ )
1442
+ ) {
1443
+ break;
1444
+ }
1445
+ this.#undoStack.pop();
1446
+ }
1447
+
1448
+ if (this.#undoStack.length === 0) {
1449
+ if (this.onChange) {
1450
+ this.onChange(this.getText());
1451
+ }
1452
+ return;
1453
+ }
1454
+
1455
+ this.#applyUndo();
1456
+ }
1457
+
1458
+ setText(text: string): void {
1459
+ this.#historyIndex = -1; // Exit history browsing mode
1460
+ this.#resetKillSequence();
1461
+ this.#setTextInternal(text);
1462
+ }
1463
+
1464
+ #exitHistoryForEditing(): void {
1465
+ if (this.#historyIndex === -1) return;
1466
+ if (this.#state.cursorLine === 0 && this.#state.cursorCol === 0) {
1467
+ this.#state.cursorLine = this.#state.lines.length - 1;
1468
+ const line = this.#state.lines[this.#state.cursorLine] || "";
1469
+ this.#setCursorCol(line.length);
1470
+ }
1471
+ this.#historyIndex = -1;
1472
+ }
1473
+
1474
+ /** Insert text at the current cursor position */
1475
+ insertText(text: string): void {
1476
+ this.#exitHistoryForEditing();
1477
+ this.#resetKillSequence();
1478
+ this.#recordUndoState();
1479
+
1480
+ const line = this.#state.lines[this.#state.cursorLine] || "";
1481
+ const inserted = insertTextNfcAt(line, this.#state.cursorCol, text);
1482
+
1483
+ this.#state.lines[this.#state.cursorLine] = inserted.line;
1484
+ this.#setCursorCol(inserted.cursorCol);
1485
+
1486
+ if (this.onChange) {
1487
+ this.onChange(this.getText());
1488
+ }
1489
+ }
1490
+
1491
+ // All the editor methods from before...
1492
+ #insertCharacter(char: string): void {
1493
+ this.#exitHistoryForEditing();
1494
+ this.#resetKillSequence();
1495
+ this.#recordUndoState();
1496
+
1497
+ const line = this.#state.lines[this.#state.cursorLine] || "";
1498
+ const inserted = insertTextNfcAt(line, this.#state.cursorCol, char);
1499
+
1500
+ this.#state.lines[this.#state.cursorLine] = inserted.line;
1501
+ this.#setCursorCol(inserted.cursorCol);
1502
+
1503
+ if (this.onChange) {
1504
+ this.onChange(this.getText());
1505
+ }
1506
+
1507
+ // Synchronous inline replacement (e.g. emoji shortcodes `:joy:` → 😂).
1508
+ // Runs before autocomplete trigger so the popup doesn't briefly chase a
1509
+ // prefix that's about to be rewritten.
1510
+ if (char.length === 1 && this.#autocompleteProvider?.trySyncInlineReplace) {
1511
+ const replaceLine = this.#state.lines[this.#state.cursorLine] || "";
1512
+ const textBeforeCursor = replaceLine.slice(0, this.#state.cursorCol);
1513
+ const replacement = this.#autocompleteProvider.trySyncInlineReplace(textBeforeCursor);
1514
+ if (replacement) {
1515
+ const before = replaceLine.slice(0, this.#state.cursorCol - replacement.replaceLen);
1516
+ const after = replaceLine.slice(this.#state.cursorCol);
1517
+ this.#state.lines[this.#state.cursorLine] = before + replacement.insert + after;
1518
+ this.#setCursorCol(before.length + replacement.insert.length);
1519
+ if (this.onChange) {
1520
+ this.onChange(this.getText());
1521
+ }
1522
+ if (this.#autocompleteState) {
1523
+ this.#cancelAutocomplete();
1524
+ this.onAutocompleteUpdate?.();
1525
+ }
1526
+ return;
1527
+ }
1528
+ }
1529
+
1530
+ // Check if we should trigger or update autocomplete
1531
+ if (!this.#autocompleteState) {
1532
+ // Auto-trigger for "/" at the start of a line (slash commands)
1533
+ if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
1534
+ this.#tryTriggerAutocomplete();
1535
+ }
1536
+ // Auto-trigger for "@" file reference (fuzzy search)
1537
+ else if (char === "@") {
1538
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1539
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1540
+ // Only trigger if @ is after whitespace or at start of line
1541
+ const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
1542
+ if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
1543
+ this.#tryTriggerAutocomplete();
1544
+ }
1545
+ }
1546
+ // Auto-trigger for "#" prompt actions anywhere in the current token
1547
+ else if (char === "#") {
1548
+ this.#tryTriggerAutocomplete();
1549
+ }
1550
+ // Also auto-trigger when typing letters/path chars in a completable context
1551
+ else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
1552
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1553
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1554
+ // Check if we're in a slash command (with or without space for arguments)
1555
+ if (this.#isInSubmittedSlashCommandContext()) {
1556
+ this.#tryTriggerAutocomplete();
1557
+ }
1558
+ // Check if we're in an @ file reference context
1559
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1560
+ this.#tryTriggerAutocomplete();
1561
+ }
1562
+ // Check if we're in a # prompt action context
1563
+ else if (textBeforeCursor.match(/#[^\s#]*$/)) {
1564
+ this.#tryTriggerAutocomplete();
1565
+ }
1566
+ // Check if we're in a :emoji shortcode context
1567
+ else if (textBeforeCursor.match(/(?:^|[\s([{>]):[a-zA-Z0-9_+-]*$/)) {
1568
+ this.#tryTriggerAutocomplete();
1569
+ }
1570
+ }
1571
+ } else {
1572
+ this.#debouncedUpdateAutocomplete();
1573
+ }
1574
+ }
1575
+
1576
+ #handlePaste(pastedText: string): void {
1577
+ this.#historyIndex = -1; // Exit history browsing mode
1578
+ this.#resetKillSequence();
1579
+ this.#recordUndoState();
1580
+
1581
+ this.#withUndoSuspended(() => {
1582
+ // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1583
+ // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1584
+ // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1585
+ // per-char filter below preserves newlines instead of stripping ESC and
1586
+ // leaking the printable tail (e.g. "[106;5u") into the editor.
1587
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1588
+ const cp = Number(code);
1589
+ if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1590
+ if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1591
+ return match;
1592
+ });
1593
+
1594
+ // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
1595
+ // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
1596
+ // land in the buffer as the same precomposed syllables a terminal
1597
+ // renders — without this, cursor column accounting drifts by
1598
+ // `(NFD cells − NFC cells)` and the visible glyph desyncs from the
1599
+ // hardware cursor. Matches the `Input` component's prior fix; this
1600
+ // is the same fix on the real GJC prompt component (`Editor`).
1601
+ const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
1602
+
1603
+ // Convert tabs to spaces (4 spaces per tab)
1604
+ const tabExpandedText = cleanText.replace(/\t/g, " ");
1605
+
1606
+ // Filter out non-printable characters except newlines
1607
+ let filteredText = tabExpandedText
1608
+ .split("")
1609
+ .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1610
+ .join("");
1611
+
1612
+ // If pasting a file path (starts with /, ~, or .) and the character before
1613
+ // the cursor is a word character, prepend a space for better readability
1614
+ if (/^[/~.]/.test(filteredText)) {
1615
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1616
+ const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
1617
+ if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
1618
+ filteredText = ` ${filteredText}`;
1619
+ }
1620
+ }
1621
+
1622
+ // Split into lines
1623
+ const pastedLines = filteredText.split("\n");
1624
+
1625
+ // Check if this is a large paste (> 10 lines or > 1000 characters)
1626
+ const totalChars = filteredText.length;
1627
+ if (pastedLines.length > 10 || totalChars > 1000) {
1628
+ // Store the paste and insert a marker
1629
+ this.#pasteCounter++;
1630
+ const pasteId = this.#pasteCounter;
1631
+ this.#pastes.set(pasteId, filteredText);
1632
+
1633
+ // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
1634
+ const marker =
1635
+ pastedLines.length > 10
1636
+ ? `[paste #${pasteId} +${pastedLines.length} lines]`
1637
+ : `[paste #${pasteId} ${totalChars} chars]`;
1638
+ this.#insertTextAtCursor(marker);
1639
+
1640
+ return;
1641
+ }
1642
+
1643
+ if (pastedLines.length === 1) {
1644
+ // Single line - insert character by character to trigger autocomplete
1645
+ for (const char of filteredText) {
1646
+ this.#insertCharacter(char);
1647
+ }
1648
+ return;
1649
+ }
1650
+
1651
+ // Multi-line paste - use insertTextAtCursor for proper handling
1652
+ this.#insertTextAtCursor(filteredText);
1653
+ });
1654
+ }
1655
+
1656
+ #addNewLine(): void {
1657
+ this.#historyIndex = -1; // Exit history browsing mode
1658
+ this.#resetKillSequence();
1659
+ this.#recordUndoState();
1660
+
1661
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1662
+
1663
+ const before = currentLine.slice(0, this.#state.cursorCol);
1664
+ const after = currentLine.slice(this.#state.cursorCol);
1665
+
1666
+ // Split current line
1667
+ this.#state.lines[this.#state.cursorLine] = before;
1668
+ this.#state.lines.splice(this.#state.cursorLine + 1, 0, after);
1669
+
1670
+ // Move cursor to start of new line
1671
+ this.#state.cursorLine++;
1672
+ this.#setCursorCol(0);
1673
+
1674
+ if (this.onChange) {
1675
+ this.onChange(this.getText());
1676
+ }
1677
+ }
1678
+
1679
+ #shouldSubmitOnBackslashEnter(data: string, kb: KeybindingsManager): boolean {
1680
+ if (this.disableSubmit) return false;
1681
+ if (!matchesKey(data, "enter")) return false;
1682
+ const submitKeys = kb.getKeys("tui.input.submit");
1683
+ const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
1684
+ if (!hasShiftEnter) return false;
1685
+
1686
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1687
+ return this.#state.cursorCol > 0 && currentLine[this.#state.cursorCol - 1] === "\\";
1688
+ }
1689
+
1690
+ #submitValue(): void {
1691
+ this.#resetKillSequence();
1692
+
1693
+ const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
1694
+
1695
+ this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
1696
+ this.#pastes.clear();
1697
+ this.#pasteCounter = 0;
1698
+ this.#historyIndex = -1;
1699
+ this.#scrollOffset = 0;
1700
+ this.#undoStack.length = 0;
1701
+
1702
+ if (this.onChange) this.onChange("");
1703
+ if (this.onSubmit) this.onSubmit(result);
1704
+ }
1705
+
1706
+ #handleBackspace(): void {
1707
+ this.#historyIndex = -1; // Exit history browsing mode
1708
+ this.#resetKillSequence();
1709
+ this.#recordUndoState();
1710
+
1711
+ if (this.#state.cursorCol > 0) {
1712
+ // Delete grapheme before cursor (handles emojis, combining characters, etc.)
1713
+ const line = this.#state.lines[this.#state.cursorLine] || "";
1714
+ const beforeCursor = line.slice(0, this.#state.cursorCol);
1715
+
1716
+ // Find the last grapheme in the text before cursor
1717
+ const graphemes = [...segmenter.segment(beforeCursor)];
1718
+ const lastGrapheme = graphemes[graphemes.length - 1];
1719
+ const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1720
+
1721
+ const before = line.slice(0, this.#state.cursorCol - graphemeLength);
1722
+ const after = line.slice(this.#state.cursorCol);
1723
+
1724
+ this.#state.lines[this.#state.cursorLine] = before + after;
1725
+ this.#setCursorCol(this.#state.cursorCol - graphemeLength);
1726
+ } else if (this.#state.cursorLine > 0) {
1727
+ // Merge with previous line
1728
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1729
+ const previousLine = this.#state.lines[this.#state.cursorLine - 1] || "";
1730
+
1731
+ this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine;
1732
+ this.#state.lines.splice(this.#state.cursorLine, 1);
1733
+
1734
+ this.#state.cursorLine--;
1735
+ this.#setCursorCol(previousLine.length);
1736
+ }
1737
+
1738
+ if (this.onChange) {
1739
+ this.onChange(this.getText());
1740
+ }
1741
+
1742
+ // Update or re-trigger autocomplete after backspace
1743
+ if (this.#autocompleteState) {
1744
+ this.#debouncedUpdateAutocomplete();
1745
+ } else {
1746
+ // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
1747
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1748
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1749
+ // Slash command context
1750
+ if (this.#isInSubmittedSlashCommandContext()) {
1751
+ this.#tryTriggerAutocomplete();
1752
+ }
1753
+ // @ file reference context
1754
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1755
+ this.#tryTriggerAutocomplete();
1756
+ }
1757
+ // # prompt action context
1758
+ else if (textBeforeCursor.match(/#[^\s#]*$/)) {
1759
+ this.#tryTriggerAutocomplete();
1760
+ }
1761
+ }
1762
+ }
1763
+
1764
+ /**
1765
+ * Set cursor column and clear preferredVisualCol.
1766
+ * Use this for all non-vertical cursor movements to reset sticky column behavior.
1767
+ */
1768
+ #setCursorCol(col: number): void {
1769
+ this.#state.cursorCol = col;
1770
+ this.#preferredVisualCol = null;
1771
+ }
1772
+
1773
+ /**
1774
+ * Move cursor to a target visual line, applying sticky column logic.
1775
+ * Shared by moveCursor() and pageScroll().
1776
+ */
1777
+ #moveToVisualLine(
1778
+ visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
1779
+ currentVisualLine: number,
1780
+ targetVisualLine: number,
1781
+ ): void {
1782
+ const currentVL = visualLines[currentVisualLine];
1783
+ const targetVL = visualLines[targetVisualLine];
1784
+
1785
+ if (currentVL && targetVL) {
1786
+ const currentVisualCol = this.#state.cursorCol - currentVL.startCol;
1787
+
1788
+ // For non-last segments, clamp to length-1 to stay within the segment
1789
+ const isLastSourceSegment =
1790
+ currentVisualLine === visualLines.length - 1 ||
1791
+ visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1792
+ const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
1793
+
1794
+ const isLastTargetSegment =
1795
+ targetVisualLine === visualLines.length - 1 ||
1796
+ visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1797
+ const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
1798
+
1799
+ const moveToVisualCol = this.#computeVerticalMoveColumn(
1800
+ currentVisualCol,
1801
+ sourceMaxVisualCol,
1802
+ targetMaxVisualCol,
1803
+ );
1804
+
1805
+ // Set cursor position
1806
+ this.#state.cursorLine = targetVL.logicalLine;
1807
+ const targetCol = targetVL.startCol + moveToVisualCol;
1808
+ const logicalLine = this.#state.lines[targetVL.logicalLine] || "";
1809
+ this.#state.cursorCol = Math.min(targetCol, logicalLine.length);
1810
+ }
1811
+ }
1812
+
1813
+ /**
1814
+ * Compute the target visual column for vertical cursor movement.
1815
+ * Implements the sticky column decision table.
1816
+ */
1817
+ #computeVerticalMoveColumn(
1818
+ currentVisualCol: number,
1819
+ sourceMaxVisualCol: number,
1820
+ targetMaxVisualCol: number,
1821
+ ): number {
1822
+ const hasPreferred = this.#preferredVisualCol !== null;
1823
+ const cursorInMiddle = currentVisualCol < sourceMaxVisualCol;
1824
+ const targetTooShort = targetMaxVisualCol < currentVisualCol;
1825
+
1826
+ if (!hasPreferred || cursorInMiddle) {
1827
+ if (targetTooShort) {
1828
+ this.#preferredVisualCol = currentVisualCol;
1829
+ return targetMaxVisualCol;
1830
+ }
1831
+ this.#preferredVisualCol = null;
1832
+ return currentVisualCol;
1833
+ }
1834
+
1835
+ const targetCantFitPreferred = targetMaxVisualCol < this.#preferredVisualCol!;
1836
+ if (targetTooShort || targetCantFitPreferred) {
1837
+ return targetMaxVisualCol;
1838
+ }
1839
+
1840
+ const result = this.#preferredVisualCol!;
1841
+ this.#preferredVisualCol = null;
1842
+ return result;
1843
+ }
1844
+
1845
+ #moveToLineStart(): void {
1846
+ this.#resetKillSequence();
1847
+ this.#setCursorCol(0);
1848
+ }
1849
+
1850
+ #moveToLineEnd(): void {
1851
+ this.#resetKillSequence();
1852
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1853
+ this.#setCursorCol(currentLine.length);
1854
+ }
1855
+
1856
+ #moveToMessageStart(): void {
1857
+ this.#resetKillSequence();
1858
+ this.#state.cursorLine = 0;
1859
+ this.#setCursorCol(0);
1860
+ }
1861
+
1862
+ #moveToMessageEnd(): void {
1863
+ this.#resetKillSequence();
1864
+ this.#state.cursorLine = this.#state.lines.length - 1;
1865
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1866
+ this.#setCursorCol(currentLine.length);
1867
+ }
1868
+
1869
+ #resetKillSequence(): void {
1870
+ this.#lastAction = null;
1871
+ }
1872
+
1873
+ #withUndoSuspended<T>(fn: () => T): T {
1874
+ const wasSuspended = this.#suspendUndo;
1875
+ this.#suspendUndo = true;
1876
+ try {
1877
+ return fn();
1878
+ } finally {
1879
+ this.#suspendUndo = wasSuspended;
1880
+ }
1881
+ }
1882
+
1883
+ #recordUndoState(): void {
1884
+ if (this.#suspendUndo) return;
1885
+ this.#undoStack.push(structuredClone(this.#state));
1886
+ }
1887
+
1888
+ #applyUndo(): void {
1889
+ const snapshot = this.#undoStack.pop();
1890
+ if (!snapshot) return;
1891
+
1892
+ this.#historyIndex = -1;
1893
+ this.#resetKillSequence();
1894
+ this.#preferredVisualCol = null;
1895
+ Object.assign(this.#state, snapshot);
1896
+
1897
+ if (this.onChange) {
1898
+ this.onChange(this.getText());
1899
+ }
1900
+
1901
+ if (this.#autocompleteState) {
1902
+ this.#debouncedUpdateAutocomplete();
1903
+ } else {
1904
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1905
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1906
+ if (this.#isInSubmittedSlashCommandContext()) {
1907
+ this.#tryTriggerAutocomplete();
1908
+ } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1909
+ this.#tryTriggerAutocomplete();
1910
+ } else if (textBeforeCursor.match(/#[^\s#]*$/)) {
1911
+ this.#tryTriggerAutocomplete();
1912
+ }
1913
+ }
1914
+ }
1915
+
1916
+ #matchesTransientUndoSnapshot(
1917
+ snapshot: EditorState,
1918
+ transientText: string,
1919
+ transientStartCol: number,
1920
+ beforeTransient: string,
1921
+ afterTransient: string,
1922
+ ): boolean {
1923
+ if (snapshot.cursorLine !== this.#state.cursorLine) return false;
1924
+ if (snapshot.lines.length !== this.#state.lines.length) return false;
1925
+
1926
+ const transientLength = snapshot.cursorCol - transientStartCol;
1927
+ if (transientLength < 0 || transientLength >= transientText.length) return false;
1928
+
1929
+ for (let i = 0; i < snapshot.lines.length; i++) {
1930
+ if (i === this.#state.cursorLine) continue;
1931
+ if (snapshot.lines[i] !== this.#state.lines[i]) return false;
1932
+ }
1933
+
1934
+ return (
1935
+ snapshot.lines[snapshot.cursorLine] ===
1936
+ beforeTransient + transientText.slice(0, transientLength) + afterTransient
1937
+ );
1938
+ }
1939
+
1940
+ #recordKill(text: string, direction: "forward" | "backward", accumulate = this.#lastAction === "kill"): void {
1941
+ if (!text) return;
1942
+ this.#killRing.push(text, { prepend: direction === "backward", accumulate });
1943
+ this.#lastAction = "kill";
1944
+ }
1945
+
1946
+ #insertTextAtCursor(text: string): void {
1947
+ this.#historyIndex = -1;
1948
+ this.#resetKillSequence();
1949
+ this.#recordUndoState();
1950
+
1951
+ const normalized = text.replace(/\r\n?/g, "\n").normalize("NFC");
1952
+ const lines = normalized.split("\n");
1953
+
1954
+ if (lines.length === 1) {
1955
+ const line = this.#state.lines[this.#state.cursorLine] || "";
1956
+ const inserted = insertTextNfcAt(line, this.#state.cursorCol, normalized);
1957
+ this.#state.lines[this.#state.cursorLine] = inserted.line;
1958
+ this.#setCursorCol(inserted.cursorCol);
1959
+ } else {
1960
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1961
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
1962
+ const afterCursor = currentLine.slice(this.#state.cursorCol);
1963
+
1964
+ const newLines: string[] = [];
1965
+ for (let i = 0; i < this.#state.cursorLine; i++) {
1966
+ newLines.push(this.#state.lines[i] || "");
1967
+ }
1968
+
1969
+ newLines.push((beforeCursor + (lines[0] || "")).normalize("NFC"));
1970
+ for (let i = 1; i < lines.length - 1; i++) {
1971
+ newLines.push(lines[i] || "");
1972
+ }
1973
+ const finalLineBeforeAfter = (lines[lines.length - 1] || "").normalize("NFC");
1974
+ newLines.push((finalLineBeforeAfter + afterCursor).normalize("NFC"));
1975
+
1976
+ for (let i = this.#state.cursorLine + 1; i < this.#state.lines.length; i++) {
1977
+ newLines.push(this.#state.lines[i] || "");
1978
+ }
1979
+
1980
+ this.#state.lines = newLines;
1981
+ this.#state.cursorLine += lines.length - 1;
1982
+ this.#setCursorCol(finalLineBeforeAfter.length);
1983
+ }
1984
+
1985
+ if (this.onChange) {
1986
+ this.onChange(this.getText());
1987
+ }
1988
+ }
1989
+
1990
+ #yankFromKillRing(): void {
1991
+ const text = this.#killRing.peek();
1992
+ if (!text) return;
1993
+ this.#insertTextAtCursor(text);
1994
+ this.#lastAction = "yank";
1995
+ }
1996
+
1997
+ #yankPop(): void {
1998
+ if (this.#lastAction !== "yank") return;
1999
+ if (this.#killRing.length <= 1) return;
2000
+
2001
+ this.#historyIndex = -1;
2002
+ this.#recordUndoState();
2003
+
2004
+ this.#withUndoSuspended(() => {
2005
+ if (!this.#deleteYankedText()) return;
2006
+ this.#killRing.rotate();
2007
+ const text = this.#killRing.peek();
2008
+ if (text) {
2009
+ this.#insertTextAtCursor(text);
2010
+ }
2011
+ });
2012
+
2013
+ this.#lastAction = "yank";
2014
+ }
2015
+
2016
+ /**
2017
+ * Delete the most recently yanked text from the buffer.
2018
+ *
2019
+ * This is a best-effort operation and assumes the cursor is still positioned
2020
+ * at the end of the yanked text.
2021
+ */
2022
+ #deleteYankedText(): boolean {
2023
+ const yankedText = this.#killRing.peek();
2024
+ if (!yankedText) return false;
2025
+
2026
+ const yankLines = yankedText.split("\n");
2027
+ const endLine = this.#state.cursorLine;
2028
+ const endCol = this.#state.cursorCol;
2029
+ const startLine = endLine - (yankLines.length - 1);
2030
+ if (startLine < 0) return false;
2031
+
2032
+ if (yankLines.length === 1) {
2033
+ const line = this.#state.lines[endLine] ?? "";
2034
+ const startCol = endCol - yankedText.length;
2035
+ if (startCol < 0) return false;
2036
+ if (line.slice(startCol, endCol) !== yankedText) return false;
2037
+
2038
+ this.#state.lines[endLine] = line.slice(0, startCol) + line.slice(endCol);
2039
+ this.#state.cursorLine = endLine;
2040
+ this.#setCursorCol(startCol);
2041
+ return true;
2042
+ }
2043
+
2044
+ const firstInserted = yankLines[0] ?? "";
2045
+ const lastInserted = yankLines[yankLines.length - 1] ?? "";
2046
+ const firstLineText = this.#state.lines[startLine] ?? "";
2047
+ const lastLineText = this.#state.lines[endLine] ?? "";
2048
+
2049
+ if (!firstLineText.endsWith(firstInserted)) return false;
2050
+ if (endCol !== lastInserted.length) return false;
2051
+ if (lastLineText.slice(0, endCol) !== lastInserted) return false;
2052
+
2053
+ const startCol = firstLineText.length - firstInserted.length;
2054
+ if (startCol < 0) return false;
2055
+
2056
+ const suffix = lastLineText.slice(endCol);
2057
+ const newLine = firstLineText.slice(0, startCol) + suffix;
2058
+
2059
+ this.#state.lines.splice(startLine, yankLines.length, newLine);
2060
+ this.#state.cursorLine = startLine;
2061
+ this.#setCursorCol(startCol);
2062
+ return true;
2063
+ }
2064
+
2065
+ #deleteToStartOfLine(): void {
2066
+ this.#historyIndex = -1; // Exit history browsing mode
2067
+ this.#recordUndoState();
2068
+
2069
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2070
+ let deletedText = "";
2071
+
2072
+ if (this.#state.cursorCol > 0) {
2073
+ // Delete from start of line up to cursor
2074
+ deletedText = currentLine.slice(0, this.#state.cursorCol);
2075
+ this.#state.lines[this.#state.cursorLine] = currentLine.slice(this.#state.cursorCol);
2076
+ this.#setCursorCol(0);
2077
+ } else if (this.#state.cursorLine > 0) {
2078
+ // At start of line - merge with previous line
2079
+ deletedText = "\n";
2080
+ const previousLine = this.#state.lines[this.#state.cursorLine - 1] || "";
2081
+ this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine;
2082
+ this.#state.lines.splice(this.#state.cursorLine, 1);
2083
+ this.#state.cursorLine--;
2084
+ this.#setCursorCol(previousLine.length);
2085
+ }
2086
+
2087
+ this.#recordKill(deletedText, "backward");
2088
+
2089
+ if (this.onChange) {
2090
+ this.onChange(this.getText());
2091
+ }
2092
+ }
2093
+
2094
+ #deleteToEndOfLine(): void {
2095
+ this.#historyIndex = -1; // Exit history browsing mode
2096
+ this.#recordUndoState();
2097
+
2098
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2099
+ let deletedText = "";
2100
+
2101
+ if (this.#state.cursorCol < currentLine.length) {
2102
+ // Delete from cursor to end of line
2103
+ deletedText = currentLine.slice(this.#state.cursorCol);
2104
+ this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, this.#state.cursorCol);
2105
+ } else if (this.#state.cursorLine < this.#state.lines.length - 1) {
2106
+ // At end of line - merge with next line
2107
+ const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
2108
+ deletedText = "\n";
2109
+ this.#state.lines[this.#state.cursorLine] = currentLine + nextLine;
2110
+ this.#state.lines.splice(this.#state.cursorLine + 1, 1);
2111
+ }
2112
+
2113
+ this.#recordKill(deletedText, "forward");
2114
+
2115
+ if (this.onChange) {
2116
+ this.onChange(this.getText());
2117
+ }
2118
+ }
2119
+
2120
+ #deleteWordBackwards(): void {
2121
+ this.#historyIndex = -1; // Exit history browsing mode
2122
+ this.#recordUndoState();
2123
+
2124
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2125
+
2126
+ // If at start of line, behave like backspace at column 0 (merge with previous line)
2127
+ if (this.#state.cursorCol === 0) {
2128
+ if (this.#state.cursorLine > 0) {
2129
+ this.#recordKill("\n", "backward");
2130
+ const previousLine = this.#state.lines[this.#state.cursorLine - 1] || "";
2131
+ this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine;
2132
+ this.#state.lines.splice(this.#state.cursorLine, 1);
2133
+ this.#state.cursorLine--;
2134
+ this.#setCursorCol(previousLine.length);
2135
+ }
2136
+ } else {
2137
+ const oldCursorCol = this.#state.cursorCol;
2138
+ this.#moveWordBackwards();
2139
+ const deleteFrom = this.#state.cursorCol;
2140
+ this.#setCursorCol(oldCursorCol);
2141
+
2142
+ const deletedText = currentLine.slice(deleteFrom, oldCursorCol);
2143
+ this.#state.lines[this.#state.cursorLine] =
2144
+ currentLine.slice(0, deleteFrom) + currentLine.slice(this.#state.cursorCol);
2145
+ this.#setCursorCol(deleteFrom);
2146
+ this.#recordKill(deletedText, "backward");
2147
+ }
2148
+
2149
+ if (this.onChange) {
2150
+ this.onChange(this.getText());
2151
+ }
2152
+ }
2153
+
2154
+ #deleteWordForwards(): void {
2155
+ this.#historyIndex = -1; // Exit history browsing mode
2156
+ this.#recordUndoState();
2157
+
2158
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2159
+
2160
+ if (this.#state.cursorCol >= currentLine.length) {
2161
+ if (this.#state.cursorLine < this.#state.lines.length - 1) {
2162
+ this.#recordKill("\n", "forward");
2163
+ const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
2164
+ this.#state.lines[this.#state.cursorLine] = currentLine + nextLine;
2165
+ this.#state.lines.splice(this.#state.cursorLine + 1, 1);
2166
+ }
2167
+ } else {
2168
+ const oldCursorCol = this.#state.cursorCol;
2169
+ this.#moveWordForwards();
2170
+ const deleteTo = this.#state.cursorCol;
2171
+ this.#setCursorCol(oldCursorCol);
2172
+
2173
+ const deletedText = currentLine.slice(oldCursorCol, deleteTo);
2174
+ this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo);
2175
+ this.#recordKill(deletedText, "forward");
2176
+ }
2177
+
2178
+ if (this.onChange) {
2179
+ this.onChange(this.getText());
2180
+ }
2181
+ }
2182
+
2183
+ #handleForwardDelete(): void {
2184
+ this.#historyIndex = -1; // Exit history browsing mode
2185
+ this.#resetKillSequence();
2186
+ this.#recordUndoState();
2187
+
2188
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2189
+
2190
+ if (this.#state.cursorCol < currentLine.length) {
2191
+ // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
2192
+ const afterCursor = currentLine.slice(this.#state.cursorCol);
2193
+
2194
+ // Find the first grapheme at cursor
2195
+ const graphemes = [...segmenter.segment(afterCursor)];
2196
+ const firstGrapheme = graphemes[0];
2197
+ const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
2198
+
2199
+ const before = currentLine.slice(0, this.#state.cursorCol);
2200
+ const after = currentLine.slice(this.#state.cursorCol + graphemeLength);
2201
+ this.#state.lines[this.#state.cursorLine] = before + after;
2202
+ } else if (this.#state.cursorLine < this.#state.lines.length - 1) {
2203
+ // At end of line - merge with next line
2204
+ const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
2205
+ this.#state.lines[this.#state.cursorLine] = currentLine + nextLine;
2206
+ this.#state.lines.splice(this.#state.cursorLine + 1, 1);
2207
+ }
2208
+
2209
+ if (this.onChange) {
2210
+ this.onChange(this.getText());
2211
+ }
2212
+
2213
+ // Update or re-trigger autocomplete after forward delete
2214
+ if (this.#autocompleteState) {
2215
+ this.#debouncedUpdateAutocomplete();
2216
+ } else {
2217
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2218
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2219
+ // Slash command context
2220
+ if (this.#isInSubmittedSlashCommandContext()) {
2221
+ this.#tryTriggerAutocomplete();
2222
+ }
2223
+ // @ file reference context
2224
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
2225
+ this.#tryTriggerAutocomplete();
2226
+ }
2227
+ // # prompt action context
2228
+ else if (textBeforeCursor.match(/#[^\s#]*$/)) {
2229
+ this.#tryTriggerAutocomplete();
2230
+ }
2231
+ }
2232
+ }
2233
+
2234
+ /**
2235
+ * Build a mapping from visual lines to logical positions.
2236
+ * Returns an array where each element represents a visual line with:
2237
+ * - logicalLine: index into this.#state.lines
2238
+ * - startCol: starting column in the logical line
2239
+ * - length: length of this visual line segment
2240
+ */
2241
+ #buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> {
2242
+ const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = [];
2243
+
2244
+ for (let i = 0; i < this.#state.lines.length; i++) {
2245
+ const line = this.#state.lines[i] || "";
2246
+ const lineVisWidth = visibleWidth(line);
2247
+ if (line.length === 0) {
2248
+ // Empty line still takes one visual line
2249
+ visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
2250
+ } else if (lineVisWidth <= width) {
2251
+ visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
2252
+ } else {
2253
+ // Line needs wrapping - use word-aware wrapping
2254
+ const chunks = wordWrapLine(line, width);
2255
+ for (const chunk of chunks) {
2256
+ visualLines.push({
2257
+ logicalLine: i,
2258
+ startCol: chunk.startIndex,
2259
+ length: chunk.endIndex - chunk.startIndex,
2260
+ });
2261
+ }
2262
+ }
2263
+ }
2264
+
2265
+ return visualLines;
2266
+ }
2267
+
2268
+ /**
2269
+ * Find the visual line index for the current cursor position.
2270
+ */
2271
+ #findCurrentVisualLine(visualLines: Array<{ logicalLine: number; startCol: number; length: number }>): number {
2272
+ for (let i = 0; i < visualLines.length; i++) {
2273
+ const vl = visualLines[i];
2274
+ if (!vl) continue;
2275
+ if (vl.logicalLine === this.#state.cursorLine) {
2276
+ const colInSegment = this.#state.cursorCol - vl.startCol;
2277
+ // Cursor is in this segment if it's within range
2278
+ // For the last segment of a logical line, cursor can be at length (end position)
2279
+ const isLastSegmentOfLine =
2280
+ i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
2281
+ if (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {
2282
+ return i;
2283
+ }
2284
+ }
2285
+ }
2286
+ // Fallback: return last visual line
2287
+ return visualLines.length - 1;
2288
+ }
2289
+
2290
+ #moveCursor(deltaLine: number, deltaCol: number): void {
2291
+ this.#resetKillSequence();
2292
+ const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
2293
+ const currentVisualLine = this.#findCurrentVisualLine(visualLines);
2294
+
2295
+ if (deltaLine !== 0) {
2296
+ const targetVisualLine = currentVisualLine + deltaLine;
2297
+
2298
+ if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
2299
+ this.#moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
2300
+ }
2301
+ }
2302
+
2303
+ if (deltaCol !== 0) {
2304
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2305
+
2306
+ if (deltaCol > 0) {
2307
+ // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
2308
+ if (this.#state.cursorCol < currentLine.length) {
2309
+ const afterCursor = currentLine.slice(this.#state.cursorCol);
2310
+ const graphemes = [...segmenter.segment(afterCursor)];
2311
+ const firstGrapheme = graphemes[0];
2312
+ this.#setCursorCol(this.#state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
2313
+ } else if (this.#state.cursorLine < this.#state.lines.length - 1) {
2314
+ // Wrap to start of next logical line
2315
+ this.#state.cursorLine++;
2316
+ this.#setCursorCol(0);
2317
+ } else {
2318
+ // At end of last line - can't move, but set preferredVisualCol for up/down navigation
2319
+ const currentVL = visualLines[currentVisualLine];
2320
+ if (currentVL) {
2321
+ this.#preferredVisualCol = this.#state.cursorCol - currentVL.startCol;
2322
+ }
2323
+ }
2324
+ } else {
2325
+ // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
2326
+ if (this.#state.cursorCol > 0) {
2327
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2328
+ const graphemes = [...segmenter.segment(beforeCursor)];
2329
+ const lastGrapheme = graphemes[graphemes.length - 1];
2330
+ this.#setCursorCol(this.#state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
2331
+ } else if (this.#state.cursorLine > 0) {
2332
+ // Wrap to end of previous logical line
2333
+ this.#state.cursorLine--;
2334
+ const prevLine = this.#state.lines[this.#state.cursorLine] || "";
2335
+ this.#setCursorCol(prevLine.length);
2336
+ }
2337
+ }
2338
+ }
2339
+ }
2340
+
2341
+ #pageScroll(direction: -1 | 1): void {
2342
+ this.#resetKillSequence();
2343
+ const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
2344
+ const currentVisualLine = this.#findCurrentVisualLine(visualLines);
2345
+ const step = this.#getPageScrollStep(visualLines.length);
2346
+ const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * step));
2347
+ if (targetVisualLine === currentVisualLine) return;
2348
+ this.#moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
2349
+ }
2350
+
2351
+ #moveWordBackwards(): void {
2352
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2353
+
2354
+ // If at start of line, move to end of previous line
2355
+ if (this.#state.cursorCol === 0) {
2356
+ if (this.#state.cursorLine > 0) {
2357
+ this.#state.cursorLine--;
2358
+ const prevLine = this.#state.lines[this.#state.cursorLine] || "";
2359
+ this.#setCursorCol(prevLine.length);
2360
+ }
2361
+ return;
2362
+ }
2363
+
2364
+ this.#setCursorCol(moveWordLeft(currentLine, this.#state.cursorCol));
2365
+ }
2366
+
2367
+ /**
2368
+ * Jump to the first occurrence of a character in the specified direction.
2369
+ * Multi-line search. Case-sensitive. Skips the current cursor position.
2370
+ */
2371
+ #jumpToChar(char: string, direction: "forward" | "backward"): void {
2372
+ this.#resetKillSequence();
2373
+ const isForward = direction === "forward";
2374
+ const lines = this.#state.lines;
2375
+
2376
+ const end = isForward ? lines.length : -1;
2377
+ const step = isForward ? 1 : -1;
2378
+
2379
+ for (let lineIdx = this.#state.cursorLine; lineIdx !== end; lineIdx += step) {
2380
+ const line = lines[lineIdx] || "";
2381
+ const isCurrentLine = lineIdx === this.#state.cursorLine;
2382
+
2383
+ // Current line: start after/before cursor; other lines: search full line
2384
+ const searchFrom = isCurrentLine
2385
+ ? isForward
2386
+ ? this.#state.cursorCol + 1
2387
+ : this.#state.cursorCol - 1
2388
+ : undefined;
2389
+
2390
+ const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
2391
+
2392
+ if (idx !== -1) {
2393
+ this.#state.cursorLine = lineIdx;
2394
+ this.#setCursorCol(idx);
2395
+ return;
2396
+ }
2397
+ }
2398
+ // No match found - cursor stays in place
2399
+ }
2400
+
2401
+ #moveWordForwards(): void {
2402
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2403
+
2404
+ // If at end of line, move to start of next line
2405
+ if (this.#state.cursorCol >= currentLine.length) {
2406
+ if (this.#state.cursorLine < this.#state.lines.length - 1) {
2407
+ this.#state.cursorLine++;
2408
+ this.#setCursorCol(0);
2409
+ }
2410
+ return;
2411
+ }
2412
+
2413
+ this.#setCursorCol(moveWordRight(currentLine, this.#state.cursorCol));
2414
+ }
2415
+
2416
+ #hasOnlyWhitespaceBeforeCursorLine(): boolean {
2417
+ for (let i = 0; i < this.#state.cursorLine; i++) {
2418
+ if ((this.#state.lines[i] || "").trim() !== "") {
2419
+ return false;
2420
+ }
2421
+ }
2422
+ return true;
2423
+ }
2424
+
2425
+ // Slash commands execute only when the submitted prompt starts with the command.
2426
+ #isAtStartOfSubmittedMessage(): boolean {
2427
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2428
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2429
+
2430
+ return this.#hasOnlyWhitespaceBeforeCursorLine() && (beforeCursor.trim() === "" || beforeCursor.trim() === "/");
2431
+ }
2432
+
2433
+ #isInSubmittedSlashCommandContext(): boolean {
2434
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2435
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2436
+ return this.#hasOnlyWhitespaceBeforeCursorLine() && beforeCursor.trimStart().startsWith("/");
2437
+ }
2438
+
2439
+ #isSlashCommandNameAutocompleteSelection(): boolean {
2440
+ if (this.#autocompleteState !== "regular") {
2441
+ return false;
2442
+ }
2443
+
2444
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2445
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol).trimStart();
2446
+ return (
2447
+ this.#isInSubmittedSlashCommandContext() && textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")
2448
+ );
2449
+ }
2450
+
2451
+ #isCompletedSlashCommandAtCursor(): boolean {
2452
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2453
+ if (this.#state.cursorCol !== currentLine.length) {
2454
+ return false;
2455
+ }
2456
+
2457
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol).trimStart();
2458
+ return this.#isInSubmittedSlashCommandContext() && /^\/\S+ $/.test(textBeforeCursor);
2459
+ }
2460
+
2461
+ // Autocomplete methods
2462
+ async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
2463
+ if (!this.#autocompleteProvider) return;
2464
+ // Check if we should trigger file completion on Tab
2465
+ if (explicitTab) {
2466
+ const provider = this.#autocompleteProvider as CombinedAutocompleteProvider;
2467
+ const shouldTrigger =
2468
+ !provider.shouldTriggerFileCompletion ||
2469
+ provider.shouldTriggerFileCompletion(this.#state.lines, this.#state.cursorLine, this.#state.cursorCol);
2470
+ if (!shouldTrigger) {
2471
+ return;
2472
+ }
2473
+ }
2474
+
2475
+ const requestId = ++this.#autocompleteRequestId;
2476
+
2477
+ const suggestions = await this.#autocompleteProvider.getSuggestions(
2478
+ this.#state.lines,
2479
+ this.#state.cursorLine,
2480
+ this.#state.cursorCol,
2481
+ );
2482
+ if (requestId !== this.#autocompleteRequestId) return;
2483
+
2484
+ if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
2485
+ this.#autocompletePrefix = suggestions.prefix;
2486
+ this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
2487
+ this.#autocompleteState = "regular";
2488
+ this.onAutocompleteUpdate?.();
2489
+ } else {
2490
+ this.#cancelAutocomplete();
2491
+ this.onAutocompleteUpdate?.();
2492
+ }
2493
+ }
2494
+ #createAutocompleteList(
2495
+ prefix: string,
2496
+ items: Array<{ value: string; label: string; description?: string }>,
2497
+ ): SelectList {
2498
+ // Layout options prepared for future SelectList enhancements (e.g., for slash commands)
2499
+ const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
2500
+ // TODO: Pass layout to SelectList when constructor is updated to support it
2501
+ void layout; // Use layout variable to avoid lint warnings
2502
+ return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList);
2503
+ }
2504
+
2505
+ #handleTabCompletion(): void {
2506
+ if (!this.#autocompleteProvider) return;
2507
+
2508
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2509
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2510
+
2511
+ // Check if we're in a slash command context
2512
+ if (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) {
2513
+ this.#handleSlashCommandCompletion();
2514
+ } else {
2515
+ this.#forceFileAutocomplete(true);
2516
+ }
2517
+ }
2518
+
2519
+ #handleSlashCommandCompletion(): void {
2520
+ this.#tryTriggerAutocomplete(true);
2521
+ }
2522
+
2523
+ /*
2524
+ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
2525
+ 17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
2526
+ 536643416/job/55932288317 havea look at .gi
2527
+ */
2528
+ async #forceFileAutocomplete(explicitTab: boolean = false): Promise<void> {
2529
+ if (!this.#autocompleteProvider) return;
2530
+
2531
+ // Check if provider supports force file suggestions via runtime check
2532
+ const provider = this.#autocompleteProvider as {
2533
+ getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"];
2534
+ };
2535
+ if (typeof provider.getForceFileSuggestions !== "function") {
2536
+ await this.#tryTriggerAutocomplete(true);
2537
+ return;
2538
+ }
2539
+
2540
+ const requestId = ++this.#autocompleteRequestId;
2541
+ const suggestions = await provider.getForceFileSuggestions(
2542
+ this.#state.lines,
2543
+ this.#state.cursorLine,
2544
+ this.#state.cursorCol,
2545
+ );
2546
+ if (requestId !== this.#autocompleteRequestId) return;
2547
+
2548
+ if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
2549
+ // If there's exactly one suggestion and this was an explicit Tab press, apply it immediately
2550
+ if (explicitTab && suggestions.items.length === 1) {
2551
+ const item = suggestions.items[0]!;
2552
+ const result = this.#autocompleteProvider.applyCompletion(
2553
+ this.#state.lines,
2554
+ this.#state.cursorLine,
2555
+ this.#state.cursorCol,
2556
+ item,
2557
+ suggestions.prefix,
2558
+ );
2559
+
2560
+ this.#state.lines = result.lines;
2561
+ this.#state.cursorLine = result.cursorLine;
2562
+ this.#setCursorCol(result.cursorCol);
2563
+
2564
+ if (this.onChange) {
2565
+ this.onChange(this.getText());
2566
+ }
2567
+ return;
2568
+ }
2569
+
2570
+ this.#autocompletePrefix = suggestions.prefix;
2571
+ this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
2572
+ this.#autocompleteState = "force";
2573
+ this.onAutocompleteUpdate?.();
2574
+ } else {
2575
+ this.#cancelAutocomplete();
2576
+ this.onAutocompleteUpdate?.();
2577
+ }
2578
+ }
2579
+
2580
+ #cancelAutocomplete(notifyCancel: boolean = false): void {
2581
+ const wasAutocompleting = this.#autocompleteState !== null;
2582
+ this.#clearAutocompleteTimeout();
2583
+ this.#autocompleteRequestId += 1;
2584
+ this.#autocompleteState = null;
2585
+ this.#autocompleteList = undefined;
2586
+ this.#autocompletePrefix = "";
2587
+ if (notifyCancel && wasAutocompleting) {
2588
+ this.onAutocompleteCancel?.();
2589
+ }
2590
+ }
2591
+
2592
+ isShowingAutocomplete(): boolean {
2593
+ return this.#autocompleteState !== null;
2594
+ }
2595
+
2596
+ async #updateAutocomplete(): Promise<void> {
2597
+ if (!this.#autocompleteState || !this.#autocompleteProvider) return;
2598
+
2599
+ // In force mode, use forceFileAutocomplete to get suggestions
2600
+ if (this.#autocompleteState === "force") {
2601
+ this.#forceFileAutocomplete();
2602
+ return;
2603
+ }
2604
+
2605
+ const requestId = ++this.#autocompleteRequestId;
2606
+
2607
+ const suggestions = await this.#autocompleteProvider.getSuggestions(
2608
+ this.#state.lines,
2609
+ this.#state.cursorLine,
2610
+ this.#state.cursorCol,
2611
+ );
2612
+ if (requestId !== this.#autocompleteRequestId) return;
2613
+
2614
+ if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
2615
+ this.#autocompletePrefix = suggestions.prefix;
2616
+ // Always create new SelectList to ensure update
2617
+ this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
2618
+ this.onAutocompleteUpdate?.();
2619
+ } else {
2620
+ this.#cancelAutocomplete();
2621
+ this.onAutocompleteUpdate?.();
2622
+ }
2623
+ }
2624
+
2625
+ #debouncedUpdateAutocomplete(): void {
2626
+ if (this.#autocompleteTimeout) {
2627
+ clearTimeout(this.#autocompleteTimeout);
2628
+ }
2629
+ this.#autocompleteTimeout = setTimeout(() => {
2630
+ this.#updateAutocomplete();
2631
+ this.#autocompleteTimeout = undefined;
2632
+ }, 100);
2633
+ }
2634
+
2635
+ #clearAutocompleteTimeout(): void {
2636
+ if (this.#autocompleteTimeout) {
2637
+ clearTimeout(this.#autocompleteTimeout);
2638
+ this.#autocompleteTimeout = undefined;
2639
+ }
2640
+ }
2641
+
2642
+ /**
2643
+ * Get inline hint text to show as dim ghost text after the cursor.
2644
+ * Checks selected autocomplete item's hint first, then falls back to provider.
2645
+ */
2646
+ #getInlineHint(): string | null {
2647
+ // Check selected autocomplete item for a hint
2648
+ if (this.#autocompleteState && this.#autocompleteList) {
2649
+ const selected = this.#autocompleteList.getSelectedItem();
2650
+ return selected?.hint ?? null;
2651
+ }
2652
+
2653
+ // Fall back to provider's getInlineHint
2654
+ if (this.#autocompleteProvider?.getInlineHint) {
2655
+ return this.#autocompleteProvider.getInlineHint(
2656
+ this.#state.lines,
2657
+ this.#state.cursorLine,
2658
+ this.#state.cursorCol,
2659
+ );
2660
+ }
2661
+
2662
+ return null;
2663
+ }
2664
+ }