@linxiraos/pi-tui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +2219 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +116 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +162 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +25 -0
  10. package/dist/types/components/loader.d.ts +25 -0
  11. package/dist/types/components/markdown.d.ts +88 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +69 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +27 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/desktop-notify.d.ts +52 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +48 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +197 -0
  25. package/dist/types/keys.d.ts +210 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +76 -0
  28. package/dist/types/latex-block.d.ts +8 -0
  29. package/dist/types/latex-to-unicode.d.ts +50 -0
  30. package/dist/types/loop-watchdog.d.ts +44 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +285 -0
  35. package/dist/types/terminal.d.ts +175 -0
  36. package/dist/types/tmux.d.ts +6 -0
  37. package/dist/types/ttyid.d.ts +9 -0
  38. package/dist/types/tui.d.ts +457 -0
  39. package/dist/types/utils.d.ts +100 -0
  40. package/package.json +70 -0
  41. package/src/autocomplete.ts +1079 -0
  42. package/src/bracketed-paste.ts +123 -0
  43. package/src/components/box.ts +236 -0
  44. package/src/components/cancellable-loader.ts +40 -0
  45. package/src/components/editor.ts +3301 -0
  46. package/src/components/image.ts +460 -0
  47. package/src/components/input.ts +482 -0
  48. package/src/components/loader.ts +174 -0
  49. package/src/components/markdown.ts +3119 -0
  50. package/src/components/scroll-view.ts +227 -0
  51. package/src/components/select-list.ts +539 -0
  52. package/src/components/settings-list.ts +793 -0
  53. package/src/components/spacer.ts +32 -0
  54. package/src/components/tab-bar.ts +300 -0
  55. package/src/components/text.ts +173 -0
  56. package/src/components/truncated-text.ts +69 -0
  57. package/src/deccara.ts +314 -0
  58. package/src/desktop-notify.ts +192 -0
  59. package/src/editor-component.ts +74 -0
  60. package/src/fuzzy.ts +384 -0
  61. package/src/index.ts +51 -0
  62. package/src/keybindings.ts +346 -0
  63. package/src/keys.ts +566 -0
  64. package/src/kill-ring.ts +51 -0
  65. package/src/kitty-graphics.ts +171 -0
  66. package/src/latex-block.ts +1338 -0
  67. package/src/latex-to-unicode.ts +2017 -0
  68. package/src/loop-watchdog.ts +115 -0
  69. package/src/mouse.ts +105 -0
  70. package/src/stdin-buffer.ts +781 -0
  71. package/src/symbols.ts +26 -0
  72. package/src/terminal-capabilities.ts +1211 -0
  73. package/src/terminal.ts +1854 -0
  74. package/src/tmux.ts +14 -0
  75. package/src/ttyid.ts +84 -0
  76. package/src/tui.ts +4275 -0
  77. package/src/utils.ts +619 -0
@@ -0,0 +1,3301 @@
1
+ import { getProjectDir, logger } from "@linxiraos/pi-utils";
2
+ import {
3
+ type AutocompleteProvider,
4
+ findLeadingSlashCommandStart,
5
+ findTrailingSlashCommandStart,
6
+ midPromptSkillTokenMatches,
7
+ } from "../autocomplete";
8
+ import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
9
+ import { canonicalKeyId, getKeybindings, type KeybindingsManager } from "../keybindings";
10
+ import { extractPrintableText, matchesKey, parseKey } from "../keys";
11
+ import { KillRing } from "../kill-ring";
12
+ import type { SymbolTheme } from "../symbols";
13
+ import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
14
+ import {
15
+ getSegmenter,
16
+ getWidthConfigEpoch,
17
+ getWordNavKind,
18
+ moveWordLeft,
19
+ moveWordRight,
20
+ padding,
21
+ replaceTabs,
22
+ sliceByColumn,
23
+ truncateToWidth,
24
+ visibleWidth,
25
+ } from "../utils";
26
+ import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
27
+
28
+ const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
29
+ overflowSearch: false,
30
+ };
31
+
32
+ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
33
+ minPrimaryColumnWidth: 12,
34
+ maxPrimaryColumnWidth: 32,
35
+ wrapDescription: true,
36
+ overflowSearch: false,
37
+ };
38
+
39
+ function sanitizeLoadedText(text: string): string {
40
+ // Normalize CRLF/CR → LF, then strip C0 control chars except \n.
41
+ return replaceTabs(text.replace(/\r\n?/g, "\n")).replace(/[\x00-\x09\x0b-\x1f]/g, "");
42
+ }
43
+
44
+ const segmenter = getSegmenter();
45
+
46
+ /**
47
+ * Represents a chunk of text for word-wrap layout.
48
+ * Tracks the text content, its position in the original line, and its exact
49
+ * visible width (`width === visibleWidth(text)`, measured at build time) so
50
+ * layout/render never re-measure cached chunks.
51
+ */
52
+ interface TextChunk {
53
+ text: string;
54
+ startIndex: number;
55
+ endIndex: number;
56
+ width: number;
57
+ }
58
+
59
+ /**
60
+ * Split a line into word-wrapped chunks.
61
+ * Wraps at word boundaries when possible, falling back to character-level
62
+ * wrapping for words longer than the available width.
63
+ *
64
+ * Widths are carried, never recomputed: the line is segmented exactly once,
65
+ * per-grapheme widths are measured lazily at most once each, and every chunk
66
+ * is a contiguous slice of `line` (no incremental string concatenation).
67
+ *
68
+ * @param line - The text line to wrap
69
+ * @param maxWidth - Maximum visible width per chunk
70
+ * @param knownLineWidth - Caller-carried exact `visibleWidth(line)`, if already measured
71
+ * @returns Array of chunks with text, position, and exact visible width
72
+ */
73
+ function wordWrapLine(line: string, maxWidth: number, knownLineWidth?: number): TextChunk[] {
74
+ if (!line || maxWidth <= 0) {
75
+ return [{ text: "", startIndex: 0, endIndex: 0, width: 0 }];
76
+ }
77
+
78
+ const lineWidth = knownLineWidth ?? visibleWidth(line);
79
+ if (lineWidth <= maxWidth) {
80
+ return [{ text: line, startIndex: 0, endIndex: line.length, width: lineWidth }];
81
+ }
82
+
83
+ // Single segmentation pass: grapheme start offsets (with end sentinel),
84
+ // lazily-filled grapheme widths, and word/whitespace token boundaries.
85
+ const gStart: number[] = [];
86
+ const gWidth: number[] = [];
87
+ interface Token {
88
+ startG: number;
89
+ endG: number;
90
+ startIndex: number;
91
+ endIndex: number;
92
+ isWhitespace: boolean;
93
+ }
94
+ const tokens: Token[] = [];
95
+ let inWhitespace = false;
96
+ let tokenStartG = 0;
97
+ let tokenStartIndex = 0;
98
+ let gCount = 0;
99
+ for (const seg of segmenter.segment(line)) {
100
+ const graphemeIsWhitespace = getWordNavKind(seg.segment) === "whitespace";
101
+ if (gCount === 0) {
102
+ inWhitespace = graphemeIsWhitespace;
103
+ } else if (graphemeIsWhitespace !== inWhitespace) {
104
+ // Token type changed - close the current token
105
+ tokens.push({
106
+ startG: tokenStartG,
107
+ endG: gCount,
108
+ startIndex: tokenStartIndex,
109
+ endIndex: seg.index,
110
+ isWhitespace: inWhitespace,
111
+ });
112
+ tokenStartG = gCount;
113
+ tokenStartIndex = seg.index;
114
+ inWhitespace = graphemeIsWhitespace;
115
+ }
116
+ gStart.push(seg.index);
117
+ gWidth.push(-1);
118
+ gCount++;
119
+ }
120
+ gStart.push(line.length);
121
+ if (gCount > tokenStartG) {
122
+ tokens.push({
123
+ startG: tokenStartG,
124
+ endG: gCount,
125
+ startIndex: tokenStartIndex,
126
+ endIndex: line.length,
127
+ isWhitespace: inWhitespace,
128
+ });
129
+ }
130
+
131
+ /** Exact `visibleWidth` of grapheme `g`, measured at most once. */
132
+ const graphemeWidth = (g: number): number => {
133
+ let w = gWidth[g] ?? -1;
134
+ if (w < 0) {
135
+ w = visibleWidth(line.slice(gStart[g] ?? 0, gStart[g + 1] ?? line.length));
136
+ gWidth[g] = w;
137
+ }
138
+ return w;
139
+ };
140
+
141
+ const chunks: TextChunk[] = [];
142
+ const pushChunk = (text: string, startIndex: number, endIndex: number): void => {
143
+ chunks.push({ text, startIndex, endIndex, width: visibleWidth(text) });
144
+ };
145
+
146
+ /** Widest grapheme prefix of [startG, endG) that fits `availableWidth`. */
147
+ const consumePrefixToWidth = (
148
+ startG: number,
149
+ endG: number,
150
+ availableWidth: number,
151
+ ): { endG: number; len: number } => {
152
+ let prefixWidth = 0;
153
+ let g = startG;
154
+ while (g < endG) {
155
+ const w = graphemeWidth(g);
156
+ if (prefixWidth + w > availableWidth) break;
157
+ prefixWidth += w;
158
+ g++;
159
+ if (prefixWidth === availableWidth) break;
160
+ }
161
+ return { endG: g, len: (gStart[g] ?? 0) - (gStart[startG] ?? 0) };
162
+ };
163
+ const hasWideGrapheme = (startG: number, endG: number): boolean => {
164
+ for (let g = startG; g < endG; g++) {
165
+ if (graphemeWidth(g) > 1) return true;
166
+ }
167
+ return false;
168
+ };
169
+
170
+ // Build chunks using word wrapping. The pending chunk is always the
171
+ // contiguous slice line[chunkStart, chunkEnd) with visible width currentWidth.
172
+ let chunkStart = 0;
173
+ let chunkEnd = 0;
174
+ let currentWidth = 0;
175
+ let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)
176
+
177
+ for (const token of tokens) {
178
+ const tokenWidth = visibleWidth(line.slice(token.startIndex, token.endIndex));
179
+
180
+ // Skip leading whitespace at line start. Keep the skipped run mapped onto the
181
+ // preceding chunk (when one exists) so every cursor position resolves to a
182
+ // layout line instead of falling through to the buffer's last visual line.
183
+ if (atLineStart && token.isWhitespace) {
184
+ const prev = chunks[chunks.length - 1];
185
+ if (prev) prev.endIndex = token.endIndex;
186
+ chunkStart = token.endIndex;
187
+ chunkEnd = token.endIndex;
188
+ continue;
189
+ }
190
+ atLineStart = false;
191
+
192
+ // If this single token is wider than maxWidth, we need to break it
193
+ if (tokenWidth > maxWidth) {
194
+ // If we're mid-line, try to use the remaining width by consuming a prefix of this long token.
195
+ let consumedPrefixLen = 0; // JS string index (code units) consumed from the token
196
+ let consumedPrefixEndG = token.startG;
197
+ if (chunkEnd > chunkStart && currentWidth < maxWidth) {
198
+ const remainingWidth = maxWidth - currentWidth;
199
+ const consumed = consumePrefixToWidth(token.startG, token.endG, remainingWidth);
200
+ consumedPrefixEndG = consumed.endG;
201
+ consumedPrefixLen = consumed.len;
202
+ }
203
+ // First, push any accumulated chunk (optionally filled with the prefix).
204
+ if (chunkEnd > chunkStart) {
205
+ if (consumedPrefixLen > 0) {
206
+ const endIndex = token.startIndex + consumedPrefixLen;
207
+ pushChunk(line.slice(chunkStart, endIndex), chunkStart, endIndex);
208
+ chunkStart = endIndex;
209
+ chunkEnd = endIndex;
210
+ } else {
211
+ pushChunk(line.slice(chunkStart, chunkEnd), chunkStart, token.startIndex);
212
+ chunkStart = token.startIndex;
213
+ chunkEnd = token.startIndex;
214
+ }
215
+ currentWidth = 0;
216
+ }
217
+ // Break the remaining long token by grapheme
218
+ let tcStart = token.startIndex + consumedPrefixLen;
219
+ let tcEnd = tcStart;
220
+ let tcWidth = 0;
221
+ for (let g = consumedPrefixEndG; g < token.endG; g++) {
222
+ const w = graphemeWidth(g);
223
+ const gEnd = gStart[g + 1] ?? line.length;
224
+ if (tcWidth + w > maxWidth && tcEnd > tcStart) {
225
+ pushChunk(line.slice(tcStart, tcEnd), tcStart, tcEnd);
226
+ tcStart = tcEnd;
227
+ tcWidth = w;
228
+ } else {
229
+ tcWidth += w;
230
+ }
231
+ tcEnd = gEnd;
232
+ }
233
+ // Keep remainder as start of next chunk
234
+ if (tcEnd > tcStart) {
235
+ chunkStart = tcStart;
236
+ chunkEnd = tcEnd;
237
+ currentWidth = tcWidth;
238
+ }
239
+ continue;
240
+ }
241
+
242
+ // Check if adding this token would exceed width
243
+ if (currentWidth + tokenWidth > maxWidth) {
244
+ // For wide-character tokens (e.g., CJK runs), prefer using remaining width before wrapping
245
+ // the whole token to the next line. This avoids leaving a short ASCII word alone.
246
+ if (
247
+ chunkEnd > chunkStart &&
248
+ !token.isWhitespace &&
249
+ currentWidth < maxWidth &&
250
+ hasWideGrapheme(token.startG, token.endG)
251
+ ) {
252
+ const remainingWidth = maxWidth - currentWidth;
253
+ const consumed = consumePrefixToWidth(token.startG, token.endG, remainingWidth);
254
+ if (consumed.len > 0) {
255
+ const endIndex = token.startIndex + consumed.len;
256
+ pushChunk(line.slice(chunkStart, endIndex), chunkStart, endIndex);
257
+ const remainder = line.slice(endIndex, token.endIndex);
258
+ chunkStart = endIndex;
259
+ chunkEnd = token.endIndex;
260
+ currentWidth = visibleWidth(remainder);
261
+ atLineStart = false;
262
+ continue;
263
+ }
264
+ }
265
+ // Push current chunk (trimming trailing whitespace for display)
266
+ const trimmedChunk = line.slice(chunkStart, chunkEnd).trimEnd();
267
+ if (trimmedChunk || chunks.length === 0) {
268
+ pushChunk(trimmedChunk, chunkStart, chunkEnd);
269
+ } else {
270
+ // All-whitespace chunk collapsed away: keep its span mapped on the
271
+ // previous chunk so cursor positions inside it stay addressable.
272
+ const prev = chunks[chunks.length - 1];
273
+ if (prev) prev.endIndex = chunkEnd;
274
+ }
275
+ // Start new line - skip leading whitespace
276
+ atLineStart = true;
277
+ if (token.isWhitespace) {
278
+ // Extend the preceding chunk over the whitespace run skipped at the wrap
279
+ // point; otherwise cursor positions inside it map to no layout line.
280
+ const prev = chunks[chunks.length - 1];
281
+ if (prev) prev.endIndex = token.endIndex;
282
+ chunkStart = token.endIndex;
283
+ chunkEnd = token.endIndex;
284
+ currentWidth = 0;
285
+ } else {
286
+ chunkStart = token.startIndex;
287
+ chunkEnd = token.endIndex;
288
+ currentWidth = tokenWidth;
289
+ atLineStart = false;
290
+ }
291
+ } else {
292
+ // Add token to current chunk
293
+ if (chunkEnd === chunkStart) chunkStart = token.startIndex;
294
+ chunkEnd = token.endIndex;
295
+ currentWidth += tokenWidth;
296
+ }
297
+ }
298
+
299
+ // Push final chunk
300
+ if (chunkEnd > chunkStart) {
301
+ pushChunk(line.slice(chunkStart, chunkEnd), chunkStart, line.length);
302
+ }
303
+
304
+ return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0, width: 0 }];
305
+ }
306
+
307
+ /** Visual cell column of code-unit `offset` within `text`, counted by grapheme walk. */
308
+ function visualColAtOffset(text: string, offset: number): number {
309
+ if (offset <= 0) return 0;
310
+ let col = 0;
311
+ for (const seg of segmenter.segment(text)) {
312
+ if (seg.index >= offset) break;
313
+ col += visibleWidth(seg.segment);
314
+ }
315
+ return col;
316
+ }
317
+
318
+ /** Code-unit offset of visual cell `col` within `text`, snapped to a grapheme
319
+ * boundary so the result never splits a surrogate pair or cluster. */
320
+ function offsetAtVisualCol(text: string, col: number): number {
321
+ if (col <= 0) return 0;
322
+ let current = 0;
323
+ for (const seg of segmenter.segment(text)) {
324
+ const width = visibleWidth(seg.segment);
325
+ if (current + width > col) return seg.index;
326
+ current += width;
327
+ }
328
+ return text.length;
329
+ }
330
+
331
+ /** Highest visual column the cursor may occupy on a wrap segment: the full width
332
+ * on a logical line's last segment, otherwise just before the final grapheme
333
+ * (the segment end is the next segment's start). */
334
+ function maxSegmentVisualCol(text: string, isLastSegment: boolean): number {
335
+ let total = 0;
336
+ let lastWidth = 0;
337
+ for (const seg of segmenter.segment(text)) {
338
+ lastWidth = visibleWidth(seg.segment);
339
+ total += lastWidth;
340
+ }
341
+ return isLastSegment ? total : Math.max(0, total - lastWidth);
342
+ }
343
+
344
+ /** True when every code unit is plain printable text: no C0 controls (so no
345
+ * ESC/CR/LF/TAB), no DEL, no C1 range — the same set `extractPrintableText`
346
+ * rejects. Such a run can never encode a key sequence. */
347
+ function isPlainTextRun(data: string): boolean {
348
+ for (let i = 0; i < data.length; i++) {
349
+ const code = data.charCodeAt(i);
350
+ if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) return false;
351
+ }
352
+ return true;
353
+ }
354
+
355
+ const DEFAULT_PAGE_SCROLL_LINES = 10;
356
+
357
+ const MAX_UNDO_STACK = 100;
358
+
359
+ interface EditorState {
360
+ lines: string[];
361
+ cursorLine: number;
362
+ cursorCol: number;
363
+ }
364
+
365
+ interface LayoutLine {
366
+ text: string;
367
+ /** Exact `visibleWidth(text)` carried from wrap/layout, never re-derived. */
368
+ width: number;
369
+ hasCursor: boolean;
370
+ cursorPos?: number;
371
+ }
372
+
373
+ /** Per-line measurement carried across renders: exact visible width plus
374
+ * lazily-built wrap chunks (only populated once the line needs wrapping). */
375
+ interface WrapEntry {
376
+ width: number;
377
+ chunks: TextChunk[] | null;
378
+ }
379
+
380
+ export interface EditorTheme {
381
+ borderColor: (str: string) => string;
382
+ selectList: SelectListTheme;
383
+ symbols: SymbolTheme;
384
+ editorPaddingX?: number;
385
+ /** Style function for inline hint/ghost text (dim text after cursor) */
386
+ hintStyle?: (text: string) => string;
387
+ }
388
+
389
+ export interface EditorTopBorder {
390
+ /** The status content (already styled) */
391
+ content: string;
392
+ /** Visible width of the content */
393
+ width: number;
394
+ }
395
+
396
+ interface HistoryEntry {
397
+ prompt: string;
398
+ }
399
+
400
+ interface HistoryStorage {
401
+ add(prompt: string, cwd?: string): Promise<void>;
402
+ getRecent(limit: number): HistoryEntry[];
403
+ }
404
+
405
+ type HistoryCursorAnchor = "start" | "end";
406
+
407
+ export class Editor implements Component, Focusable {
408
+ #state: EditorState = {
409
+ lines: [""],
410
+ cursorLine: 0,
411
+ cursorCol: 0,
412
+ };
413
+
414
+ /** Focusable interface - set by TUI when focus changes */
415
+ focused: boolean = false;
416
+
417
+ #theme: EditorTheme;
418
+ #useTerminalCursor = false;
419
+ #imeSafeCursorLayout = false;
420
+
421
+ /** When set, replaces the normal cursor glyph at end-of-text with this ANSI-styled string. */
422
+ cursorOverride: string | undefined;
423
+ /** Display width of the cursorOverride glyph (needed because override may contain ANSI escapes). */
424
+ cursorOverrideWidth: number | undefined;
425
+ /** Optional hook that decorates displayed user text after source-text layout.
426
+ * Width-changing output is allowed on lines without the cursor; it is truncated
427
+ * to the content width rather than reflowed. Cursor glyphs and inline hints are excluded. */
428
+ decorateText: ((text: string) => string) | undefined;
429
+ #promptGutter: string | undefined;
430
+
431
+ // Store last layout width for cursor navigation
432
+ #lastLayoutWidth: number = 80;
433
+ // Line measurement + word-wrap cache shared by #layoutText,
434
+ // #buildVisualLineMap, and key handlers within a frame. Line text is a
435
+ // sound key (strings are immutable); cleared on layout-width or
436
+ // width-config (Hangul jamo setting) change and size-bounded so stale
437
+ // lines don't accumulate.
438
+ #wrapCache = new Map<string, WrapEntry>();
439
+ #wrapCacheWidth = -1;
440
+ #wrapCacheEpoch = -1;
441
+ #paddingXOverride: number | undefined;
442
+ #maxHeight?: number;
443
+ #scrollOffset: number = 0;
444
+ /** When true, the right border shows a scrollbar track/thumb when content
445
+ * overflows {@link #maxHeight}. Enabled by {@link HookEditorComponent} and
446
+ * other multi-line consumers; single-line consumers are unaffected. */
447
+ #scrollbarVisible = false;
448
+
449
+ // Emacs-style kill ring
450
+ #killRing = new KillRing();
451
+ #lastAction: "kill" | "yank" | "type-word" | null = null;
452
+
453
+ // Character jump mode
454
+ #jumpMode: "forward" | "backward" | null = null;
455
+
456
+ // Preferred visual column for vertical cursor movement (sticky column)
457
+ #preferredVisualCol: number | null = null;
458
+
459
+ // Border color (can be changed dynamically)
460
+ borderColor: (str: string) => string;
461
+
462
+ // Autocomplete support
463
+ #autocompleteProvider?: AutocompleteProvider;
464
+ #autocompleteList?: SelectList;
465
+ #autocompleteState: "regular" | "force" | null = null;
466
+ #autocompletePrefix: string = "";
467
+ #autocompleteRequestId: number = 0;
468
+ #autocompleteMaxVisible: number = 5;
469
+ onAutocompleteUpdate?: () => void;
470
+
471
+ // Paste tracking for large pastes
472
+ #pastes: Map<number, string> = new Map();
473
+ #pasteCounter: number = 0;
474
+
475
+ /** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
476
+ * `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
477
+ * landing on any character of a token removes the whole token instead of corrupting it into
478
+ * stray text. MUST be a global regex; the editor recompiles a private copy so its `lastIndex`
479
+ * is never shared with the caller. */
480
+ atomicTokenPattern: RegExp | undefined;
481
+ #atomicTokenSource: string | undefined;
482
+ #atomicTokenRe: RegExp | undefined;
483
+
484
+ // Bracketed paste mode buffering
485
+ #pasteHandler = new BracketedPasteHandler();
486
+
487
+ // Prompt history for up/down navigation
488
+ #history: string[] = [];
489
+ #historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
490
+ #historyStorage?: HistoryStorage;
491
+
492
+ // Undo stack for editor state changes
493
+ #undoStack: EditorState[] = [];
494
+ #suspendUndo = false;
495
+
496
+ // Debounce timer for autocomplete updates
497
+ #autocompleteTimeout?: NodeJS.Timeout;
498
+
499
+ onSubmit?: (text: string) => void | Promise<void>;
500
+ onAltEnter?: (text: string) => void;
501
+ onChange?: (text: string) => void;
502
+ /** Called for a "marker-sized" paste — the point where the editor would otherwise collapse it
503
+ * into a `[Paste #N]` token (> 10 lines or > 1000 characters). Return `true` to intercept:
504
+ * the editor inserts nothing and records no undo state, leaving insertion to the host (e.g. a
505
+ * "wrap in a code block / XML / attach as file" menu for very large pastes), which re-inserts
506
+ * via {@link insertPaste} or {@link insertText}. Return `false` (or leave unset) for the
507
+ * default collapse-to-marker behavior. `lineCount` is the sanitized paste's line count. */
508
+ onLargePaste?: (text: string, lineCount: number) => boolean;
509
+ onAutocompleteCancel?: () => void;
510
+ disableSubmit: boolean = false;
511
+
512
+ // Custom top border (for status line integration). Either an eager `content`
513
+ // (set once, reused every frame) or a `provider` that recomputes lazily just
514
+ // before the editor paints — the second form lets the host coalesce
515
+ // per-event rebuilds down to one per rendered frame (see #4145).
516
+ #topBorderContent?: EditorTopBorder;
517
+ #topBorderProvider?: (availableWidth: number) => EditorTopBorder | undefined;
518
+ #borderVisible = true;
519
+
520
+ constructor(theme: EditorTheme) {
521
+ this.#theme = theme;
522
+ this.borderColor = theme.borderColor;
523
+ }
524
+
525
+ setAutocompleteProvider(provider: AutocompleteProvider): void {
526
+ this.#autocompleteProvider = provider;
527
+ }
528
+
529
+ /**
530
+ * Set custom content for the top border (e.g., status line).
531
+ * Pass undefined to use the default plain border.
532
+ *
533
+ * Eager: the passed value is cached and reused every frame. Callers that
534
+ * mutate status upstream must recompute and call this again. Prefer
535
+ * {@link setTopBorderProvider} for high-frequency updates — it collapses
536
+ * per-event rebuilds to one per painted frame.
537
+ */
538
+ setTopBorder(content: EditorTopBorder | undefined): void {
539
+ this.#topBorderContent = content;
540
+ }
541
+
542
+ /**
543
+ * Install a lazy provider invoked once per editor render with the current
544
+ * `availableWidth`. Overrides any eager content set via {@link setTopBorder}
545
+ * — pass `undefined` to detach and fall back to the eager slot.
546
+ *
547
+ * Use this when the top border derives from state that mutates far faster
548
+ * than the render cadence (session events, streaming, subagent updates).
549
+ * The TUI already throttles renders, so a provider is invoked at most once
550
+ * per frame and never does wasted work between paints.
551
+ */
552
+ setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void {
553
+ this.#topBorderProvider = provider;
554
+ }
555
+
556
+ /**
557
+ * Show or hide the editor border chrome.
558
+ */
559
+ setBorderVisible(borderVisible: boolean): void {
560
+ this.#borderVisible = borderVisible;
561
+ }
562
+
563
+ setPromptGutter(promptGutter: string | undefined): void {
564
+ this.#promptGutter = promptGutter;
565
+ }
566
+
567
+ /**
568
+ * Get the available width for top border content given a total terminal width.
569
+ * Accounts for the border characters and horizontal padding when visible.
570
+ */
571
+ getTopBorderAvailableWidth(terminalWidth: number): number {
572
+ const paddingX = this.#getEditorPaddingX();
573
+ const borderWidth = this.#getHorizontalChromeWidth(paddingX);
574
+ return Math.max(0, terminalWidth - borderWidth * 2);
575
+ }
576
+
577
+ /**
578
+ * Use the real terminal cursor instead of rendering a cursor glyph.
579
+ */
580
+ setUseTerminalCursor(useTerminalCursor: boolean): void {
581
+ this.#useTerminalCursor = useTerminalCursor;
582
+ }
583
+
584
+ /** Render a dedicated bottom border so terminal-local IME preedit cannot shift editor chrome. */
585
+ setImeSafeCursorLayout(enabled: boolean): void {
586
+ this.#imeSafeCursorLayout = enabled;
587
+ }
588
+
589
+ getUseTerminalCursor(): boolean {
590
+ return this.#useTerminalCursor;
591
+ }
592
+
593
+ setMaxHeight(maxHeight: number | undefined): void {
594
+ if (this.#maxHeight === maxHeight) return;
595
+ this.#maxHeight = maxHeight;
596
+ // Don't reset scrollOffset — #updateScrollOffset will clamp it on next render
597
+ }
598
+
599
+ /** Enable/disable the right-border scrollbar. Only shown when content overflows. */
600
+ setScrollbarVisible(visible: boolean): void {
601
+ this.#scrollbarVisible = visible;
602
+ }
603
+
604
+ setPaddingX(paddingX: number): void {
605
+ this.#paddingXOverride = Math.max(0, paddingX);
606
+ }
607
+
608
+ getAutocompleteMaxVisible(): number {
609
+ return this.#autocompleteMaxVisible;
610
+ }
611
+
612
+ setAutocompleteMaxVisible(maxVisible: number): void {
613
+ const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
614
+ if (this.#autocompleteMaxVisible !== newMaxVisible) {
615
+ this.#autocompleteMaxVisible = newMaxVisible;
616
+ }
617
+ }
618
+
619
+ setHistoryStorage(storage: HistoryStorage): void {
620
+ this.#historyStorage = storage;
621
+ const recent = storage.getRecent(100);
622
+ this.#history = recent.map(entry => entry.prompt);
623
+ this.#historyIndex = -1;
624
+ }
625
+
626
+ /**
627
+ * Add a prompt to history for up/down arrow navigation.
628
+ * Called after successful submission.
629
+ */
630
+ addToHistory(text: string): void {
631
+ const trimmed = text.trim();
632
+ if (!trimmed) return;
633
+ // Don't add consecutive duplicates
634
+ if (this.#history.length > 0 && this.#history[0] === trimmed) return;
635
+ this.#history.unshift(trimmed);
636
+ // Limit history size
637
+ if (this.#history.length > 100) {
638
+ this.#history.pop();
639
+ }
640
+
641
+ const stor = this.#historyStorage;
642
+ if (stor) {
643
+ stor.add(trimmed, getProjectDir()).catch(error => {
644
+ logger.error("HistoryStorage add failed", { error: String(error) });
645
+ });
646
+ }
647
+ }
648
+
649
+ #isEditorEmpty(): boolean {
650
+ return this.#state.lines.length === 1 && this.#state.lines[0] === "";
651
+ }
652
+
653
+ #isOnFirstVisualLine(): boolean {
654
+ const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
655
+ const currentVisualLine = this.#findCurrentVisualLine(visualLines);
656
+ return currentVisualLine === 0;
657
+ }
658
+
659
+ #isOnLastVisualLine(): boolean {
660
+ const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
661
+ const currentVisualLine = this.#findCurrentVisualLine(visualLines);
662
+ return currentVisualLine === visualLines.length - 1;
663
+ }
664
+
665
+ #navigateHistory(direction: 1 | -1): void {
666
+ this.#resetKillSequence();
667
+ if (this.#history.length === 0) return;
668
+ const newIndex = this.#historyIndex - direction; // Up(-1) increases index, Down(1) decreases
669
+ if (newIndex < -1 || newIndex >= this.#history.length) return;
670
+ this.#historyIndex = newIndex;
671
+ if (this.#historyIndex === -1) {
672
+ // Returned to "current" state - clear editor
673
+ this.#setTextInternal("", "end");
674
+ } else {
675
+ const cursorAnchor: HistoryCursorAnchor = direction === -1 ? "start" : "end";
676
+ this.#setTextInternal(this.#history[this.#historyIndex] || "", cursorAnchor);
677
+ }
678
+ }
679
+ /** Internal setText that doesn't reset history state - used by navigateHistory */
680
+ #setTextInternal(text: string, cursorAnchor: HistoryCursorAnchor = "end"): void {
681
+ this.#undoStack.length = 0;
682
+ const lines = sanitizeLoadedText(text).split("\n");
683
+ this.#state.lines = lines.length === 0 ? [""] : lines;
684
+ if (cursorAnchor === "start") {
685
+ this.#state.cursorLine = 0;
686
+ this.#setCursorCol(0);
687
+ } else {
688
+ this.#state.cursorLine = this.#state.lines.length - 1;
689
+ this.#setCursorCol(this.#state.lines[this.#state.cursorLine]?.length || 0);
690
+ }
691
+ if (this.onChange) {
692
+ this.onChange(this.getText());
693
+ }
694
+ }
695
+
696
+ invalidate(): void {
697
+ // No cached state to invalidate currently
698
+ }
699
+
700
+ #getEditorPaddingX(): number {
701
+ const padding = this.#paddingXOverride ?? this.#theme.editorPaddingX ?? 2;
702
+ return Math.max(0, padding);
703
+ }
704
+
705
+ #getHorizontalChromeWidth(paddingX: number): number {
706
+ return this.#borderVisible ? paddingX + 1 : 0;
707
+ }
708
+
709
+ #getPromptGutterWidth(width: number, paddingX: number): number {
710
+ if (this.#borderVisible || !this.#promptGutter) return 0;
711
+ const chromeWidth = 2 * this.#getHorizontalChromeWidth(paddingX);
712
+ const availableWidth = Math.max(0, width - chromeWidth);
713
+ return Math.min(visibleWidth(this.#promptGutter), availableWidth);
714
+ }
715
+
716
+ #getPromptGutter(
717
+ width: number,
718
+ paddingX: number,
719
+ ): { firstLine: string; continuation: string; width: number } | undefined {
720
+ if (this.#borderVisible || !this.#promptGutter) return undefined;
721
+ const gutterWidth = this.#getPromptGutterWidth(width, paddingX);
722
+ if (gutterWidth === 0) return undefined;
723
+ return {
724
+ firstLine: sliceByColumn(this.#promptGutter, 0, gutterWidth, true),
725
+ continuation: padding(gutterWidth),
726
+ width: gutterWidth,
727
+ };
728
+ }
729
+
730
+ #getContentWidth(width: number, paddingX: number): number {
731
+ const chromeWidth = 2 * this.#getHorizontalChromeWidth(paddingX);
732
+ return Math.max(0, width - chromeWidth - this.#getPromptGutterWidth(width, paddingX));
733
+ }
734
+
735
+ #getLayoutWidth(width: number, paddingX: number): number {
736
+ const contentWidth = this.#getContentWidth(width, paddingX);
737
+ const cursorReserve = this.#borderVisible && paddingX === 0 ? 1 : 0;
738
+ // Keep cursor/scroll layout addressable even when a borderless prompt gutter consumes every visible column.
739
+ return Math.max(1, contentWidth - cursorReserve);
740
+ }
741
+
742
+ #getVisibleContentHeight(contentLines: number): number {
743
+ if (this.#maxHeight === undefined) return contentLines;
744
+ const verticalChrome = this.#borderVisible ? 2 : 0;
745
+ return Math.max(1, this.#maxHeight - verticalChrome);
746
+ }
747
+
748
+ /** Apply the optional input decorator to a plain (ANSI-free) text segment.
749
+ * Decoration only adds zero-width SGR codes, so visible width is unchanged.
750
+ * Splits around CURSOR_MARKER so each user-text segment is decorated in
751
+ * isolation: the marker begins with ESC, and a keyword regex that pins
752
+ * the right boundary with `(?!\S)` would otherwise reject an otherwise-
753
+ * valid match at the cursor seam (e.g. `ultrathink` immediately followed
754
+ * by the marker stops glowing until a trailing character is typed). */
755
+ #decorate(text: string): string {
756
+ const decorate = this.decorateText;
757
+ if (decorate === undefined || text.length === 0) return text;
758
+ const idx = text.indexOf(CURSOR_MARKER);
759
+ if (idx === -1) return decorate(text);
760
+ const before = text.slice(0, idx);
761
+ const after = text.slice(idx + CURSOR_MARKER.length);
762
+ return (before.length > 0 ? decorate(before) : "") + CURSOR_MARKER + (after.length > 0 ? decorate(after) : "");
763
+ }
764
+
765
+ #getStyledInputCursor(): { text: string; width: number } {
766
+ const cursorChar = this.#theme.symbols.inputCursor;
767
+ // Keep the software cursor steady. Ghostty/cmux can leave visual
768
+ // afterimages for SGR blink cells during rapid input-row repaints.
769
+ return { text: cursorChar, width: visibleWidth(cursorChar) };
770
+ }
771
+
772
+ #renderEndOfLineCursorAtWidthLimit(
773
+ before: string,
774
+ marker: string,
775
+ maxWidth: number,
776
+ replacement?: { text: string; width: number },
777
+ ): { text: string; width: number } {
778
+ const beforeGraphemes = [...segmenter.segment(before)];
779
+ const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment;
780
+ const lastGraphemeWidth = lastGrapheme ? visibleWidth(lastGrapheme) : 0;
781
+ const builtInCursor = this.#getStyledInputCursor();
782
+ const fallbackReplacement = lastGrapheme
783
+ ? { text: `\x1b[7m${lastGrapheme}\x1b[0m`, width: lastGraphemeWidth }
784
+ : builtInCursor;
785
+ const clampReplacement = (candidate: { text: string; width: number }): { text: string; width: number } => {
786
+ let text = sliceByColumn(candidate.text, 0, maxWidth, true);
787
+ let width = visibleWidth(text);
788
+ if (width > maxWidth) {
789
+ text = "";
790
+ width = 0;
791
+ }
792
+ return { text, width };
793
+ };
794
+
795
+ let clampedReplacement = clampReplacement(replacement ?? fallbackReplacement);
796
+ if (replacement && clampedReplacement.width === 0) {
797
+ // A custom override that cannot fit at all should first fall back to the highlighted tail.
798
+ clampedReplacement = clampReplacement(fallbackReplacement);
799
+ }
800
+ if (lastGrapheme && clampedReplacement.width === 0) {
801
+ // If even the highlighted trailing grapheme cannot fit, show the built-in single-column cursor.
802
+ clampedReplacement = clampReplacement(builtInCursor);
803
+ }
804
+
805
+ const replacedSpanWidth = Math.min(maxWidth, Math.max(lastGraphemeWidth, clampedReplacement.width));
806
+ const prefixWidth = Math.max(0, maxWidth - replacedSpanWidth);
807
+ const beforePrefix = sliceByColumn(before, 0, prefixWidth, true);
808
+ const replacementPad = padding(Math.max(0, replacedSpanWidth - clampedReplacement.width));
809
+ return {
810
+ text: `${beforePrefix}${replacementPad}${clampedReplacement.text}${marker}`,
811
+ width: visibleWidth(beforePrefix) + replacedSpanWidth,
812
+ };
813
+ }
814
+
815
+ #renderTerminalCursorMarker(text: string, marker: string, maxWidth: number): string {
816
+ if (!marker) return text;
817
+ if (visibleWidth(text) < maxWidth) {
818
+ return text + marker;
819
+ }
820
+
821
+ let insertAt = text.length;
822
+ let offset = 0;
823
+ for (const seg of segmenter.segment(text)) {
824
+ if (visibleWidth(seg.segment) > 0) {
825
+ insertAt = offset;
826
+ }
827
+ offset += seg.segment.length;
828
+ }
829
+
830
+ return `${text.slice(0, insertAt)}${marker}${text.slice(insertAt)}`;
831
+ }
832
+
833
+ #getPageScrollStep(totalVisualLines: number): number {
834
+ const visibleHeight =
835
+ this.#maxHeight === undefined ? DEFAULT_PAGE_SCROLL_LINES : this.#getVisibleContentHeight(totalVisualLines);
836
+ return Math.max(1, visibleHeight - 1);
837
+ }
838
+
839
+ #updateScrollOffset(layoutWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void {
840
+ if (layoutLines.length <= visibleHeight) {
841
+ this.#scrollOffset = 0;
842
+ return;
843
+ }
844
+
845
+ const visualLines = this.#buildVisualLineMap(layoutWidth);
846
+ const cursorLine = this.#findCurrentVisualLine(visualLines);
847
+ if (cursorLine < this.#scrollOffset) {
848
+ this.#scrollOffset = cursorLine;
849
+ } else if (cursorLine >= this.#scrollOffset + visibleHeight) {
850
+ this.#scrollOffset = cursorLine - visibleHeight + 1;
851
+ }
852
+
853
+ const maxOffset = Math.max(0, layoutLines.length - visibleHeight);
854
+ this.#scrollOffset = Math.min(this.#scrollOffset, maxOffset);
855
+ }
856
+
857
+ render(width: number): readonly string[] {
858
+ const paddingX = this.#getEditorPaddingX();
859
+ const borderVisible = this.#borderVisible;
860
+ const promptGutter = this.#getPromptGutter(width, paddingX);
861
+ const contentAreaWidth = this.#getContentWidth(width, paddingX);
862
+ const layoutWidth = this.#getLayoutWidth(width, paddingX);
863
+ this.#lastLayoutWidth = layoutWidth;
864
+
865
+ // Box-drawing characters for rounded corners
866
+ const box = this.#theme.symbols.boxRound;
867
+ const borderWidth = this.#getHorizontalChromeWidth(paddingX);
868
+ const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
869
+ const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
870
+ const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`);
871
+ const horizontal = this.borderColor(box.horizontal);
872
+
873
+ // Layout the text
874
+ const layoutLines = this.#layoutText(layoutWidth);
875
+ const visibleContentHeight = this.#getVisibleContentHeight(layoutLines.length);
876
+ this.#updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight);
877
+ const visibleLayoutLines = layoutLines.slice(this.#scrollOffset, this.#scrollOffset + visibleContentHeight);
878
+
879
+ const result: string[] = [];
880
+ // Scrollbar: shown only when content overflows and the caller opted in.
881
+ const needsScrollbar = this.#scrollbarVisible && layoutLines.length > visibleContentHeight;
882
+ let scrollbarThumb: { start: number; end: number } | null = null;
883
+ if (needsScrollbar && visibleContentHeight > 0) {
884
+ const thumbSize = Math.max(
885
+ 1,
886
+ Math.min(
887
+ Math.floor((visibleContentHeight * visibleContentHeight) / layoutLines.length),
888
+ visibleContentHeight,
889
+ ),
890
+ );
891
+ const travel = visibleContentHeight - thumbSize;
892
+ const maxOffset = Math.max(0, layoutLines.length - visibleContentHeight);
893
+ const start = maxOffset === 0 ? 0 : Math.round((this.#scrollOffset / maxOffset) * travel);
894
+ scrollbarThumb = { start, end: start + thumbSize };
895
+ }
896
+
897
+ if (borderVisible) {
898
+ // Render top border: ╭─ [status content] ────────────────╮
899
+ const topFillWidth = Math.max(0, width - borderWidth * 2);
900
+ // Provider (lazy) wins over eager content — a host that installs both
901
+ // wants the coalesced path; falling back to eager keeps existing
902
+ // setTopBorder callers working unchanged.
903
+ const topBorder = this.#topBorderProvider ? this.#topBorderProvider(topFillWidth) : this.#topBorderContent;
904
+ if (topBorder) {
905
+ const { content, width: statusWidth } = topBorder;
906
+ if (statusWidth <= topFillWidth) {
907
+ // Status fits - add fill after it
908
+ const fillWidth = topFillWidth - statusWidth;
909
+ result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
910
+ } else {
911
+ // Status too long - truncate it
912
+ const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1));
913
+ const truncatedWidth = visibleWidth(truncated);
914
+ const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
915
+ result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
916
+ }
917
+ } else {
918
+ result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
919
+ }
920
+ }
921
+
922
+ // Render each layout line
923
+ // Keep the hardware cursor at the text insertion point while autocomplete
924
+ // rows render below it; terminals use that position to anchor IME candidates.
925
+ const emitCursorMarker = this.focused;
926
+ const lineContentWidth = contentAreaWidth;
927
+
928
+ // Compute inline hint text (dim ghost text after cursor)
929
+ const inlineHint = this.#getInlineHint();
930
+ const hintStyle = this.#theme.hintStyle ?? ((t: string) => `\x1b[2m${t}\x1b[0m`);
931
+
932
+ for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) {
933
+ const layoutLine = visibleLayoutLines[visibleIndex]!;
934
+ let displayText = layoutLine.text;
935
+ let displayWidth = layoutLine.width;
936
+ let cursorPaddingOverflow = 0;
937
+ let decorated = false;
938
+ let imeSafeCursorTail = false;
939
+ const showPromptGutter = promptGutter !== undefined && visibleIndex === 0;
940
+ const gutterText =
941
+ promptGutter === undefined ? "" : showPromptGutter ? promptGutter.firstLine : promptGutter.continuation;
942
+
943
+ // Add cursor if this line has it
944
+ const hasCursor = layoutLine.hasCursor && layoutLine.cursorPos !== undefined;
945
+ const marker = emitCursorMarker ? CURSOR_MARKER : "";
946
+
947
+ if (!borderVisible && displayWidth > lineContentWidth) {
948
+ displayText = sliceByColumn(displayText, 0, lineContentWidth, true);
949
+ displayWidth = visibleWidth(displayText);
950
+ }
951
+
952
+ if (!borderVisible && lineContentWidth === 0) {
953
+ if (hasCursor && !this.#useTerminalCursor) {
954
+ const zeroWidthCursorBudget = visibleWidth(gutterText);
955
+ const zeroWidthCursorReplacement = this.cursorOverride
956
+ ? { text: this.cursorOverride, width: this.cursorOverrideWidth ?? 1 }
957
+ : this.#getStyledInputCursor();
958
+ if (showPromptGutter && zeroWidthCursorBudget > 0) {
959
+ // Keep the leading prompt glyph visible when the gutter consumes the whole row.
960
+ const promptGlyph = [...segmenter.segment(gutterText)][0]?.segment ?? "";
961
+ const promptGlyphWidth = visibleWidth(promptGlyph);
962
+ const remainingCursorWidth = Math.max(0, zeroWidthCursorBudget - promptGlyphWidth);
963
+ if (remainingCursorWidth === 0) {
964
+ result.push(`\x1b[7m${promptGlyph}\x1b[0m${marker}`);
965
+ } else {
966
+ const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(
967
+ "",
968
+ marker,
969
+ remainingCursorWidth,
970
+ zeroWidthCursorReplacement,
971
+ );
972
+ result.push(`${promptGlyph}${widthLimitedCursor.text}`);
973
+ }
974
+ } else {
975
+ const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(
976
+ gutterText,
977
+ marker,
978
+ zeroWidthCursorBudget,
979
+ zeroWidthCursorReplacement,
980
+ );
981
+ result.push(widthLimitedCursor.text);
982
+ }
983
+ } else if (hasCursor && this.#useTerminalCursor) {
984
+ result.push(this.#renderTerminalCursorMarker(gutterText, marker, visibleWidth(gutterText)));
985
+ } else {
986
+ result.push(gutterText + (hasCursor ? marker : ""));
987
+ }
988
+ continue;
989
+ }
990
+
991
+ if (hasCursor && this.#useTerminalCursor) {
992
+ if (marker) {
993
+ const before = displayText.slice(0, layoutLine.cursorPos);
994
+ const after = displayText.slice(layoutLine.cursorPos);
995
+ if (this.#imeSafeCursorLayout && after.length === 0 && borderVisible) {
996
+ // Terminal frontends render IME marked text locally before committed bytes
997
+ // reach the application. Keep the end-of-input cursor row empty to its
998
+ // right so that insertion cannot shift box chrome onto the next row.
999
+ displayText = before + marker;
1000
+ imeSafeCursorTail = true;
1001
+ } else if (after.length === 0 && inlineHint) {
1002
+ const availWidth = Math.max(0, lineContentWidth - displayWidth);
1003
+ const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
1004
+ displayText = before + marker + hintText;
1005
+ displayWidth += Math.min(visibleWidth(inlineHint), availWidth);
1006
+ } else if (after.length === 0 && !borderVisible && displayWidth >= lineContentWidth) {
1007
+ displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth);
1008
+ } else {
1009
+ displayText = before + marker + after;
1010
+ }
1011
+ }
1012
+ } else if (hasCursor && !this.#useTerminalCursor) {
1013
+ const before = displayText.slice(0, layoutLine.cursorPos);
1014
+ const after = displayText.slice(layoutLine.cursorPos);
1015
+
1016
+ if (after.length > 0) {
1017
+ // Cursor is on a character (grapheme) - replace it with highlighted version
1018
+ // Get the first grapheme from 'after'
1019
+ const afterGraphemes = [...segmenter.segment(after)];
1020
+ const firstGrapheme = afterGraphemes[0]?.segment || "";
1021
+ const restAfter = after.slice(firstGrapheme.length);
1022
+ const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
1023
+ // Decorate the plain text on each side of the cursor glyph. The reverse-video
1024
+ // reset (\x1b[0m) ends in "m" (a word char), so a boundary match on restAfter
1025
+ // would fail in the whole-line fallback below — decorate the segments here.
1026
+ displayText = this.#decorate(before) + marker + cursor + this.#decorate(restAfter);
1027
+ decorated = true;
1028
+ // displayWidth stays the same - we're replacing, not adding
1029
+ } else if (this.cursorOverride) {
1030
+ // Cursor override replaces the normal end-of-text cursor glyph
1031
+ const overrideWidth = this.cursorOverrideWidth ?? 1;
1032
+ if (!borderVisible && displayWidth + overrideWidth > lineContentWidth) {
1033
+ // Borderless editors have no spare padding cell for an end-of-line cursor glyph.
1034
+ // Preserve cursorOverride by replacing the tail of the line with it.
1035
+ const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth, {
1036
+ text: this.cursorOverride,
1037
+ width: overrideWidth,
1038
+ });
1039
+ displayText = widthLimitedCursor.text;
1040
+ displayWidth = widthLimitedCursor.width;
1041
+ } else if (inlineHint) {
1042
+ const availWidth = Math.max(0, lineContentWidth - displayWidth - overrideWidth);
1043
+ const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
1044
+ displayText = before + marker + this.cursorOverride + hintText;
1045
+ displayWidth += overrideWidth + Math.min(visibleWidth(inlineHint), availWidth);
1046
+ } else {
1047
+ displayText = before + marker + this.cursorOverride;
1048
+ displayWidth += overrideWidth;
1049
+ }
1050
+ } else {
1051
+ // Cursor is at the end - add thin cursor glyph
1052
+ const { text: cursor, width: cursorWidth } = this.#getStyledInputCursor();
1053
+ if (!borderVisible && displayWidth + cursorWidth > lineContentWidth) {
1054
+ // Borderless editors have no spare padding cell for an end-of-line cursor glyph.
1055
+ // Highlight the last grapheme so the cursor stays visible without consuming width.
1056
+ const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth);
1057
+ displayText = widthLimitedCursor.text;
1058
+ displayWidth = widthLimitedCursor.width;
1059
+ } else if (inlineHint) {
1060
+ const availWidth = Math.max(0, lineContentWidth - displayWidth - cursorWidth);
1061
+ const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
1062
+ displayText = before + marker + cursor + hintText;
1063
+ displayWidth += cursorWidth + Math.min(visibleWidth(inlineHint), availWidth);
1064
+ } else {
1065
+ displayText = before + marker + cursor;
1066
+ displayWidth += cursorWidth;
1067
+ }
1068
+ if (displayWidth > lineContentWidth && paddingX > 0) {
1069
+ cursorPaddingOverflow = displayWidth - lineContentWidth;
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ // No cursor on this line, or a branch that left the user text intact: decorate
1075
+ // the whole line. `#decorate` splits around CURSOR_MARKER so a keyword glued to
1076
+ // the cursor still satisfies its right-boundary lookahead.
1077
+ if (!decorated) {
1078
+ displayText = this.#decorate(displayText);
1079
+ }
1080
+ if (!hasCursor) {
1081
+ // Undecorated, unsliced lines keep their carried width; any
1082
+ // transform above produced a new string and must be re-measured.
1083
+ displayWidth = displayText === layoutLine.text ? layoutLine.width : visibleWidth(displayText);
1084
+ if (displayWidth > lineContentWidth) {
1085
+ displayText = truncateToWidth(displayText, lineContentWidth);
1086
+ displayWidth = visibleWidth(displayText);
1087
+ }
1088
+ }
1089
+
1090
+ const linePad = padding(Math.max(0, lineContentWidth - displayWidth));
1091
+
1092
+ if (!borderVisible) {
1093
+ result.push(gutterText + displayText + linePad);
1094
+ continue;
1095
+ }
1096
+
1097
+ // All lines have consistent borders based on padding. When the end-of-line cursor
1098
+ // glyph (or a wide trailing grapheme) extends past `lineContentWidth`, shrink the
1099
+ // right chrome by the exact overflow count: drop padding spaces first, then the
1100
+ // trailing `─`, but never the corner/vertical bar itself.
1101
+ const isLastLine = visibleIndex === visibleLayoutLines.length - 1;
1102
+ const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow);
1103
+ if (isLastLine && imeSafeCursorTail) {
1104
+ const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
1105
+ const bottomBorder = this.borderColor(
1106
+ `${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`,
1107
+ );
1108
+ result.push(leftBorder + displayText);
1109
+ result.push(bottomBorder);
1110
+ continue;
1111
+ }
1112
+ if (isLastLine) {
1113
+ const rightPad = Math.max(0, rightChromeCells - 2);
1114
+ const includeHorizontal = rightChromeCells >= 2;
1115
+ const bottomRightAdjusted = this.borderColor(
1116
+ `${padding(rightPad)}${includeHorizontal ? box.horizontal : ""}${box.bottomRight}`,
1117
+ );
1118
+ result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`);
1119
+ } else {
1120
+ const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
1121
+ // When scrollbar is active, replace the right border vertical with a
1122
+ // thumb glyph (█) on lines inside the thumb range, keeping the track (│) elsewhere.
1123
+ const inThumb = scrollbarThumb && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end;
1124
+ const rightGlyph = inThumb ? "█" : box.vertical;
1125
+ const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
1126
+ result.push(leftBorder + displayText + linePad + rightBorder);
1127
+ }
1128
+ }
1129
+
1130
+ // Add autocomplete list if active
1131
+ if (this.#autocompleteState && this.#autocompleteList) {
1132
+ const autocompleteResult = this.#autocompleteList.render(width);
1133
+ result.push(...autocompleteResult);
1134
+ }
1135
+
1136
+ return result;
1137
+ }
1138
+
1139
+ handleInput(data: string): void {
1140
+ // Iterative, not recursive: the bytes trailing a completed bracketed
1141
+ // paste (which may themselves contain further pastes) loop back here,
1142
+ // so a fragmented paste stream can never grow the call stack.
1143
+ let next: string | undefined = data;
1144
+ while (next !== undefined && next.length > 0) {
1145
+ next = this.#handleInputChunk(next);
1146
+ }
1147
+ }
1148
+
1149
+ /** Process one input chunk. Returns the unconsumed tail of a completed paste, if any. */
1150
+ #handleInputChunk(data: string): string | undefined {
1151
+ const kb = getKeybindings();
1152
+ // Parse the sequence once; every binding probe below is then a set
1153
+ // lookup instead of re-parsing `data` per probe (~35 probes per key).
1154
+ const parsedKey = parseKey(data);
1155
+ const canonical = parsedKey === undefined ? undefined : canonicalKeyId(parsedKey);
1156
+
1157
+ // Handle character jump mode (awaiting next character to jump to)
1158
+ if (this.#jumpMode !== null) {
1159
+ // Cancel if the hotkey is pressed again
1160
+ if (
1161
+ kb.matchesCanonical(canonical, "tui.editor.jumpForward") ||
1162
+ kb.matchesCanonical(canonical, "tui.editor.jumpBackward")
1163
+ ) {
1164
+ this.#jumpMode = null;
1165
+ return;
1166
+ }
1167
+
1168
+ const printableText = extractPrintableText(data);
1169
+ if (printableText) {
1170
+ const direction = this.#jumpMode;
1171
+ this.#jumpMode = null;
1172
+ this.#jumpToChar(printableText, direction);
1173
+ return;
1174
+ }
1175
+
1176
+ // Control character - cancel and fall through to normal handling
1177
+ this.#jumpMode = null;
1178
+ }
1179
+
1180
+ // Handle bracketed paste mode
1181
+ const paste = this.#pasteHandler.process(data);
1182
+ if (paste.handled) {
1183
+ if (paste.pasteContent !== undefined) {
1184
+ this.#handlePaste(paste.pasteContent);
1185
+ if (paste.remaining.length > 0) {
1186
+ return paste.remaining;
1187
+ }
1188
+ }
1189
+ return;
1190
+ }
1191
+
1192
+ // Bulk printable fast path: a multi-scalar run of plain text (paste
1193
+ // remainder, batched stdin) parses to no key, so no binding probe or
1194
+ // special-key branch below can consume it — it always falls through to
1195
+ // one #insertCharacter call. Take that path directly and skip the
1196
+ // dispatch cascade. Runs containing ESC or control bytes (including
1197
+ // \r/\n) keep the full path: those bytes carry key semantics.
1198
+ if (canonical === undefined && data.length > 1 && isPlainTextRun(data)) {
1199
+ this.#insertCharacter(data);
1200
+ return;
1201
+ }
1202
+
1203
+ // Handle special key combinations first
1204
+
1205
+ // Ctrl+C is reserved by parent components for app-level handling.
1206
+ // Do not consume arbitrary user-bound "copy" keys here, since the editor
1207
+ // has no copy implementation and would make those keys disappear.
1208
+ if (matchesKey(data, "ctrl+c")) {
1209
+ return;
1210
+ }
1211
+
1212
+ // Undo
1213
+ if (kb.matchesCanonical(canonical, "tui.editor.undo")) {
1214
+ this.#applyUndo();
1215
+ return;
1216
+ }
1217
+
1218
+ // Handle autocomplete special keys first (but don't block other input)
1219
+ if (this.#autocompleteState && this.#autocompleteList) {
1220
+ // Escape - cancel autocomplete
1221
+ if (kb.matchesCanonical(canonical, "tui.select.cancel")) {
1222
+ this.#cancelAutocomplete(true);
1223
+ return;
1224
+ }
1225
+ // Let the autocomplete list handle navigation and selection
1226
+ else if (
1227
+ kb.matchesCanonical(canonical, "tui.select.up") ||
1228
+ kb.matchesCanonical(canonical, "tui.select.down") ||
1229
+ kb.matchesCanonical(canonical, "tui.select.pageUp") ||
1230
+ kb.matchesCanonical(canonical, "tui.select.pageDown") ||
1231
+ kb.matchesCanonical(canonical, "tui.input.submit") ||
1232
+ data === "\n" ||
1233
+ kb.matchesCanonical(canonical, "tui.input.tab")
1234
+ ) {
1235
+ // Only pass navigation keys to the list, not Enter/Tab (we handle those directly)
1236
+ if (
1237
+ kb.matchesCanonical(canonical, "tui.select.up") ||
1238
+ kb.matchesCanonical(canonical, "tui.select.down") ||
1239
+ kb.matchesCanonical(canonical, "tui.select.pageUp") ||
1240
+ kb.matchesCanonical(canonical, "tui.select.pageDown")
1241
+ ) {
1242
+ this.#autocompleteList.handleInput(data);
1243
+ this.onAutocompleteUpdate?.();
1244
+ return;
1245
+ }
1246
+
1247
+ // If Tab was pressed, always apply the selection
1248
+ if (kb.matchesCanonical(canonical, "tui.input.tab")) {
1249
+ const selected = this.#autocompleteList.getSelectedItem();
1250
+ // Check for stale autocomplete state due to buffer edits since last refresh
1251
+ // (destructive keys or paste can outrun the debounced update).
1252
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1253
+ const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1254
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1255
+ // Autocomplete is stale - silently cancel; Tab has no fallback action here.
1256
+ this.#cancelAutocomplete();
1257
+ return;
1258
+ }
1259
+ if (selected && this.#autocompleteProvider) {
1260
+ const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
1261
+ const result = this.#autocompleteProvider.applyCompletion(
1262
+ this.#state.lines,
1263
+ this.#state.cursorLine,
1264
+ this.#state.cursorCol,
1265
+ selected,
1266
+ this.#autocompletePrefix,
1267
+ );
1268
+
1269
+ this.#state.lines = result.lines;
1270
+ this.#state.cursorLine = result.cursorLine;
1271
+ this.#setCursorCol(result.cursorCol);
1272
+
1273
+ this.#cancelAutocomplete();
1274
+ this.onAutocompleteUpdate?.();
1275
+
1276
+ if (this.onChange) {
1277
+ this.onChange(this.getText());
1278
+ }
1279
+
1280
+ result.onApplied?.();
1281
+
1282
+ if (shouldChainSlashCommandAutocomplete && this.#isCompletedSlashCommandAtCursor()) {
1283
+ void this.#tryTriggerAutocomplete();
1284
+ }
1285
+ }
1286
+ return;
1287
+ }
1288
+
1289
+ // If Enter was pressed on a submitted slash command (not an absolute-path
1290
+ // completion sharing the leading-slash prefix), apply and submit.
1291
+ if (
1292
+ (kb.matchesCanonical(canonical, "tui.input.submit") || data === "\n") &&
1293
+ findLeadingSlashCommandStart(this.#autocompletePrefix) !== null &&
1294
+ this.#isInSubmittedSlashCommandContext() &&
1295
+ !this.#selectedCompletionIsPath()
1296
+ ) {
1297
+ const selected = this.#autocompleteList.getSelectedItem();
1298
+ // Check for stale autocomplete state due to debounce
1299
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1300
+ const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1301
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1302
+ // Autocomplete is stale - cancel and fall through to normal submission
1303
+ this.#cancelAutocomplete();
1304
+ } else {
1305
+ if (selected && this.#autocompleteProvider) {
1306
+ const result = this.#autocompleteProvider.applyCompletion(
1307
+ this.#state.lines,
1308
+ this.#state.cursorLine,
1309
+ this.#state.cursorCol,
1310
+ selected,
1311
+ this.#autocompletePrefix,
1312
+ );
1313
+
1314
+ this.#state.lines = result.lines;
1315
+ this.#state.cursorLine = result.cursorLine;
1316
+ this.#setCursorCol(result.cursorCol);
1317
+ result.onApplied?.();
1318
+ }
1319
+ this.#cancelAutocomplete();
1320
+ }
1321
+ // Don't return - fall through to submission logic
1322
+ }
1323
+ // Otherwise, apply the completion without submitting the surrounding draft.
1324
+ else if (kb.matchesCanonical(canonical, "tui.input.submit") || data === "\n") {
1325
+ const selected = this.#autocompleteList.getSelectedItem();
1326
+ // Check for stale autocomplete state due to buffer edits since last refresh.
1327
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1328
+ const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1329
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1330
+ // Autocomplete is stale - cancel and fall through to normal submission
1331
+ this.#cancelAutocomplete();
1332
+ } else {
1333
+ if (selected && this.#autocompleteProvider) {
1334
+ const result = this.#autocompleteProvider.applyCompletion(
1335
+ this.#state.lines,
1336
+ this.#state.cursorLine,
1337
+ this.#state.cursorCol,
1338
+ selected,
1339
+ this.#autocompletePrefix,
1340
+ );
1341
+
1342
+ this.#state.lines = result.lines;
1343
+ this.#state.cursorLine = result.cursorLine;
1344
+ this.#setCursorCol(result.cursorCol);
1345
+
1346
+ this.#cancelAutocomplete();
1347
+ this.onAutocompleteUpdate?.();
1348
+
1349
+ if (this.onChange) {
1350
+ this.onChange(this.getText());
1351
+ }
1352
+
1353
+ result.onApplied?.();
1354
+ }
1355
+ return;
1356
+ }
1357
+ }
1358
+ }
1359
+ // For other keys (like regular typing), DON'T return here
1360
+ // Let them fall through to normal character handling
1361
+ }
1362
+
1363
+ // Tab key - context-aware completion (but not when already autocompleting)
1364
+ if (kb.matchesCanonical(canonical, "tui.input.tab") && !this.#autocompleteState) {
1365
+ this.#handleTabCompletion();
1366
+ return;
1367
+ }
1368
+
1369
+ // Continue with rest of input handling
1370
+ // Delete to end of line
1371
+ if (kb.matchesCanonical(canonical, "tui.editor.deleteToLineEnd")) {
1372
+ this.#deleteToEndOfLine();
1373
+ }
1374
+ // Delete to start of line
1375
+ else if (kb.matchesCanonical(canonical, "tui.editor.deleteToLineStart")) {
1376
+ this.#deleteToStartOfLine();
1377
+ }
1378
+ // Delete word backward. Registry defaults cover ctrl+w, alt+backspace,
1379
+ // ctrl+backspace, and super+alt+backspace (Ghostty on macOS reports
1380
+ // Option+Backspace as super+alt — kitty mod 11, see #2064).
1381
+ else if (kb.matchesCanonical(canonical, "tui.editor.deleteWordBackward")) {
1382
+ this.#deleteWordBackwards();
1383
+ }
1384
+ // Delete word forward. Registry defaults cover alt+d/alt+delete and their
1385
+ // super+alt variants for the same Ghostty quirk.
1386
+ else if (kb.matchesCanonical(canonical, "tui.editor.deleteWordForward")) {
1387
+ this.#deleteWordForwards();
1388
+ }
1389
+ // Yank from kill ring
1390
+ else if (kb.matchesCanonical(canonical, "tui.editor.yank")) {
1391
+ this.#yankFromKillRing();
1392
+ }
1393
+ // Yank-pop (cycle kill ring)
1394
+ else if (kb.matchesCanonical(canonical, "tui.editor.yankPop")) {
1395
+ this.#yankPop();
1396
+ }
1397
+ // Ctrl+A - Move to start of line
1398
+ else if (matchesKey(data, "ctrl+a")) {
1399
+ this.#moveToLineStart();
1400
+ }
1401
+ // Ctrl+E - Move to end of line
1402
+ else if (matchesKey(data, "ctrl+e")) {
1403
+ this.#moveToLineEnd();
1404
+ }
1405
+ // Alt+Enter - special handler if callback exists, otherwise new line
1406
+ else if (matchesKey(data, "alt+enter")) {
1407
+ if (this.onAltEnter) {
1408
+ this.onAltEnter(this.getText());
1409
+ } else {
1410
+ this.#addNewLine();
1411
+ }
1412
+ }
1413
+ // New line
1414
+ else if (
1415
+ (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
1416
+ matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
1417
+ data === "\x1b\r" || // Option+Enter in some terminals (legacy)
1418
+ data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
1419
+ kb.matchesCanonical(canonical, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
1420
+ (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
1421
+ (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
1422
+ ) {
1423
+ if (this.#shouldSubmitOnBackslashEnter(data, kb)) {
1424
+ this.#handleBackspace();
1425
+ this.#submitValue();
1426
+ return;
1427
+ }
1428
+ this.#addNewLine();
1429
+ }
1430
+ // Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
1431
+ else if (kb.matchesCanonical(canonical, "tui.input.submit") || data === "\n") {
1432
+ // If submit is disabled, do nothing
1433
+ if (this.disableSubmit) {
1434
+ return;
1435
+ }
1436
+
1437
+ // Synchronous slash command completion for the race condition where
1438
+ // async autocomplete hasn't resolved yet (user types /q quickly + Enter).
1439
+ // Match the existing selected-item behavior when autocomplete IS showing.
1440
+ if (!this.#autocompleteState) {
1441
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1442
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1443
+ if (
1444
+ findLeadingSlashCommandStart(textBeforeCursor) !== null &&
1445
+ this.#isInSubmittedSlashCommandContext() &&
1446
+ this.#autocompleteProvider?.trySyncSlashCompletion
1447
+ ) {
1448
+ const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
1449
+ if (syncResult && syncResult.items.length > 0) {
1450
+ // Invalidate any pending async autocomplete so its stale results are discarded
1451
+ this.#autocompleteRequestId += 1;
1452
+ // Apply the best match and submit the completed command
1453
+ const selected = syncResult.items[0]!;
1454
+ const result = this.#autocompleteProvider.applyCompletion(
1455
+ this.#state.lines,
1456
+ this.#state.cursorLine,
1457
+ this.#state.cursorCol,
1458
+ selected,
1459
+ syncResult.prefix,
1460
+ );
1461
+ this.#state.lines = result.lines;
1462
+ this.#state.cursorLine = result.cursorLine;
1463
+ this.#setCursorCol(result.cursorCol);
1464
+ result.onApplied?.();
1465
+ }
1466
+ }
1467
+ }
1468
+
1469
+ this.#submitValue();
1470
+ }
1471
+ // Backspace (including Shift+Backspace)
1472
+ else if (kb.matchesCanonical(canonical, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
1473
+ this.#handleBackspace();
1474
+ }
1475
+ // Line navigation shortcuts (Home/End keys)
1476
+ else if (kb.matchesCanonical(canonical, "tui.editor.cursorLineStart")) {
1477
+ this.#moveToLineStart();
1478
+ } else if (kb.matchesCanonical(canonical, "tui.editor.cursorLineEnd")) {
1479
+ this.#moveToLineEnd();
1480
+ }
1481
+ // Page navigation (PageUp/PageDown): page the editor viewport only. On a
1482
+ // short draft this is a no-op — it never steps prompt history (that stays
1483
+ // on Up/Down), so an idle empty editor swallows the keys instead of
1484
+ // surprising the user by loading the previous prompt (#4754).
1485
+ else if (kb.matchesCanonical(canonical, "tui.editor.pageUp")) {
1486
+ this.#pageScroll(-1);
1487
+ } else if (kb.matchesCanonical(canonical, "tui.editor.pageDown")) {
1488
+ this.#pageScroll(1);
1489
+ }
1490
+ // Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
1491
+ else if (kb.matchesCanonical(canonical, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
1492
+ this.#handleForwardDelete();
1493
+ }
1494
+ // Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
1495
+ else if (kb.matchesCanonical(canonical, "tui.editor.cursorWordLeft")) {
1496
+ // Word left
1497
+ this.#resetKillSequence();
1498
+ this.#moveWordBackwards();
1499
+ } else if (kb.matchesCanonical(canonical, "tui.editor.cursorWordRight")) {
1500
+ // Word right
1501
+ this.#resetKillSequence();
1502
+ this.#moveWordForwards();
1503
+ }
1504
+ // Arrow keys
1505
+ else if (kb.matchesCanonical(canonical, "tui.editor.cursorUp")) {
1506
+ // Up - history navigation or cursor movement
1507
+ if (this.#isEditorEmpty()) {
1508
+ this.#navigateHistory(-1); // Start browsing history
1509
+ } else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) {
1510
+ this.#navigateHistory(-1); // Navigate to older history entry
1511
+ } else if (this.#isOnFirstVisualLine()) {
1512
+ // Already at top - jump to start of line
1513
+ this.#moveToLineStart();
1514
+ } else {
1515
+ this.#moveCursor(-1, 0); // Cursor movement (within text or history entry)
1516
+ }
1517
+ } else if (kb.matchesCanonical(canonical, "tui.editor.cursorDown")) {
1518
+ // Down - history navigation or cursor movement
1519
+ if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
1520
+ this.#navigateHistory(1); // Navigate to newer history entry or clear
1521
+ } else if (this.#isOnLastVisualLine()) {
1522
+ // Already at bottom - jump to end of line
1523
+ this.#moveToLineEnd();
1524
+ } else {
1525
+ this.#moveCursor(1, 0); // Cursor movement (within text or history entry)
1526
+ }
1527
+ } else if (kb.matchesCanonical(canonical, "tui.editor.cursorRight")) {
1528
+ // Right
1529
+ this.#moveCursor(0, 1);
1530
+ } else if (kb.matchesCanonical(canonical, "tui.editor.cursorLeft")) {
1531
+ // Left
1532
+ this.#moveCursor(0, -1);
1533
+ }
1534
+ // Shift+Space - insert regular space (Kitty protocol sends escape sequence)
1535
+ else if (matchesKey(data, "shift+space")) {
1536
+ this.#insertCharacter(" ");
1537
+ }
1538
+ // Character jump mode triggers
1539
+ else if (kb.matchesCanonical(canonical, "tui.editor.jumpForward")) {
1540
+ this.#jumpMode = "forward";
1541
+ } else if (kb.matchesCanonical(canonical, "tui.editor.jumpBackward")) {
1542
+ this.#jumpMode = "backward";
1543
+ }
1544
+ // Printable keystrokes, including Kitty CSI-u text-producing sequences.
1545
+ else {
1546
+ const printableText = extractPrintableText(data);
1547
+ if (printableText) {
1548
+ this.#insertCharacter(printableText);
1549
+ }
1550
+ }
1551
+ }
1552
+
1553
+ /** Cached per-line measurement: exact visible width now, wrap chunks on demand. */
1554
+ #lineEntry(line: string, width: number): WrapEntry {
1555
+ const epoch = getWidthConfigEpoch();
1556
+ if (width !== this.#wrapCacheWidth || epoch !== this.#wrapCacheEpoch) {
1557
+ this.#wrapCache.clear();
1558
+ this.#wrapCacheWidth = width;
1559
+ this.#wrapCacheEpoch = epoch;
1560
+ }
1561
+ let entry = this.#wrapCache.get(line);
1562
+ if (entry === undefined) {
1563
+ if (this.#wrapCache.size >= 256) {
1564
+ this.#wrapCache.clear();
1565
+ }
1566
+ entry = { width: visibleWidth(line), chunks: null };
1567
+ this.#wrapCache.set(line, entry);
1568
+ }
1569
+ return entry;
1570
+ }
1571
+
1572
+ #wrapLine(line: string, width: number): TextChunk[] {
1573
+ const entry = this.#lineEntry(line, width);
1574
+ entry.chunks ??= wordWrapLine(line, width, entry.width);
1575
+ return entry.chunks;
1576
+ }
1577
+
1578
+ #layoutText(contentWidth: number): LayoutLine[] {
1579
+ const layoutLines: LayoutLine[] = [];
1580
+
1581
+ if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
1582
+ // Empty editor
1583
+ layoutLines.push({
1584
+ text: "",
1585
+ width: 0,
1586
+ hasCursor: true,
1587
+ cursorPos: 0,
1588
+ });
1589
+ return layoutLines;
1590
+ }
1591
+
1592
+ // Process each logical line
1593
+ for (let i = 0; i < this.#state.lines.length; i++) {
1594
+ const line = this.#state.lines[i] || "";
1595
+ const isCurrentLine = i === this.#state.cursorLine;
1596
+ const lineVisibleWidth = this.#lineEntry(line, contentWidth).width;
1597
+
1598
+ if (lineVisibleWidth <= contentWidth) {
1599
+ // Line fits in one layout line
1600
+ if (isCurrentLine) {
1601
+ layoutLines.push({
1602
+ text: line,
1603
+ width: lineVisibleWidth,
1604
+ hasCursor: true,
1605
+ cursorPos: this.#state.cursorCol,
1606
+ });
1607
+ } else {
1608
+ layoutLines.push({
1609
+ text: line,
1610
+ width: lineVisibleWidth,
1611
+ hasCursor: false,
1612
+ });
1613
+ }
1614
+ } else {
1615
+ // Line needs wrapping - use word-aware wrapping
1616
+ const chunks = this.#wrapLine(line, contentWidth);
1617
+
1618
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1619
+ const chunk = chunks[chunkIndex];
1620
+ if (!chunk) continue;
1621
+
1622
+ const cursorPos = this.#state.cursorCol;
1623
+ const isLastChunk = chunkIndex === chunks.length - 1;
1624
+
1625
+ // Determine if cursor is in this chunk
1626
+ // For word-wrapped chunks, we need to handle the case where
1627
+ // cursor might be in trimmed whitespace at end of chunk
1628
+ let hasCursorInChunk = false;
1629
+ let adjustedCursorPos = 0;
1630
+
1631
+ if (isCurrentLine) {
1632
+ // The first chunk owns any leading whitespace the wrapper skipped,
1633
+ // so a cursor inside it still maps to a layout line.
1634
+ const chunkStart = chunkIndex === 0 ? 0 : chunk.startIndex;
1635
+ if (isLastChunk) {
1636
+ // Last chunk: cursor belongs here if >= startIndex
1637
+ hasCursorInChunk = cursorPos >= chunkStart;
1638
+ } else {
1639
+ // Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
1640
+ hasCursorInChunk = cursorPos >= chunkStart && cursorPos < chunk.endIndex;
1641
+ }
1642
+ if (hasCursorInChunk) {
1643
+ // Clamp into the displayed text (cursor may sit in trimmed/skipped whitespace)
1644
+ adjustedCursorPos = Math.max(0, Math.min(cursorPos - chunk.startIndex, chunk.text.length));
1645
+ }
1646
+ }
1647
+
1648
+ if (hasCursorInChunk) {
1649
+ layoutLines.push({
1650
+ text: chunk.text,
1651
+ width: chunk.width,
1652
+ hasCursor: true,
1653
+ cursorPos: adjustedCursorPos,
1654
+ });
1655
+ } else {
1656
+ layoutLines.push({
1657
+ text: chunk.text,
1658
+ width: chunk.width,
1659
+ hasCursor: false,
1660
+ });
1661
+ }
1662
+ }
1663
+ }
1664
+ }
1665
+
1666
+ return layoutLines;
1667
+ }
1668
+
1669
+ getText(): string {
1670
+ return this.#state.lines.join("\n");
1671
+ }
1672
+
1673
+ /** Whether the buffer text equals `value`, without `getText()`'s full join —
1674
+ * O(1) for the hot per-keystroke probes against short single-line values. */
1675
+ textEquals(value: string): boolean {
1676
+ const lines = this.#state.lines;
1677
+ if (lines.length === 1) return lines[0] === value;
1678
+ if (value.indexOf("\n") === -1) return false;
1679
+ return this.getText() === value;
1680
+ }
1681
+
1682
+ #expandPasteMarkers(text: string): string {
1683
+ let result = text;
1684
+ for (const [pasteId, pasteContent] of this.#pastes) {
1685
+ const markerRegex = new RegExp(`\\[Paste #${pasteId}(?:, (?:\\+\\d+ lines|\\d+ chars))?\\]`, "g");
1686
+ result = result.replace(markerRegex, () => pasteContent);
1687
+ }
1688
+ return result;
1689
+ }
1690
+
1691
+ /**
1692
+ * Get text with paste markers expanded to their actual content.
1693
+ * Use this when you need the full content (e.g., for external editor).
1694
+ */
1695
+ getExpandedText(): string {
1696
+ return this.#expandPasteMarkers(this.#state.lines.join("\n"));
1697
+ }
1698
+
1699
+ getLines(): string[] {
1700
+ return [...this.#state.lines];
1701
+ }
1702
+
1703
+ getCursor(): { line: number; col: number } {
1704
+ return { line: this.#state.cursorLine, col: this.#state.cursorCol };
1705
+ }
1706
+
1707
+ moveToLineStart(): void {
1708
+ this.#moveToLineStart();
1709
+ }
1710
+
1711
+ moveToLineEnd(): void {
1712
+ this.#moveToLineEnd();
1713
+ }
1714
+
1715
+ moveToMessageStart(): void {
1716
+ this.#moveToMessageStart();
1717
+ }
1718
+
1719
+ moveToMessageEnd(): void {
1720
+ this.#moveToMessageEnd();
1721
+ }
1722
+
1723
+ /**
1724
+ * Undo the last meaningful edit while ignoring transient text that is still present at the cursor.
1725
+ * Used for command-like autocomplete actions whose typed trigger should not count as the edit being undone.
1726
+ */
1727
+ undoPastTransientText(transientText: string): void {
1728
+ if (transientText.length === 0) {
1729
+ this.#applyUndo();
1730
+ return;
1731
+ }
1732
+
1733
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1734
+ const transientStartCol = this.#state.cursorCol - transientText.length;
1735
+ if (transientStartCol < 0 || currentLine.slice(transientStartCol, this.#state.cursorCol) !== transientText) {
1736
+ this.#applyUndo();
1737
+ return;
1738
+ }
1739
+
1740
+ const beforeTransient = currentLine.slice(0, transientStartCol);
1741
+ const afterTransient = currentLine.slice(this.#state.cursorCol);
1742
+ this.#historyIndex = -1;
1743
+ this.#resetKillSequence();
1744
+ this.#preferredVisualCol = null;
1745
+ this.#state.lines[this.#state.cursorLine] = beforeTransient + afterTransient;
1746
+ this.#setCursorCol(transientStartCol);
1747
+
1748
+ while (true) {
1749
+ const snapshot = this.#undoStack.at(-1);
1750
+ if (
1751
+ !snapshot ||
1752
+ !this.#matchesTransientUndoSnapshot(
1753
+ snapshot,
1754
+ transientText,
1755
+ transientStartCol,
1756
+ beforeTransient,
1757
+ afterTransient,
1758
+ )
1759
+ ) {
1760
+ break;
1761
+ }
1762
+ this.#undoStack.pop();
1763
+ }
1764
+
1765
+ if (this.#undoStack.length === 0) {
1766
+ if (this.onChange) {
1767
+ this.onChange(this.getText());
1768
+ }
1769
+ return;
1770
+ }
1771
+
1772
+ this.#applyUndo();
1773
+ }
1774
+
1775
+ setText(text: string): void {
1776
+ this.#historyIndex = -1; // Exit history browsing mode
1777
+ this.#resetKillSequence();
1778
+ this.#setTextInternal(text);
1779
+ }
1780
+ submit(): void {
1781
+ if (this.disableSubmit) return;
1782
+ this.#submitValue();
1783
+ }
1784
+
1785
+ #exitHistoryForEditing(): void {
1786
+ if (this.#historyIndex === -1) return;
1787
+ if (this.#state.cursorLine === 0 && this.#state.cursorCol === 0) {
1788
+ this.#state.cursorLine = this.#state.lines.length - 1;
1789
+ const line = this.#state.lines[this.#state.cursorLine] || "";
1790
+ this.#setCursorCol(line.length);
1791
+ }
1792
+ this.#historyIndex = -1;
1793
+ }
1794
+
1795
+ /** Insert text at the current cursor position */
1796
+ insertText(text: string): void {
1797
+ this.#exitHistoryForEditing();
1798
+ this.#insertTextAtCursor(text);
1799
+ }
1800
+
1801
+ /** Delete up to `count` characters immediately before the cursor on the current line.
1802
+ * Used to "track back" the auto-repeat spaces that the space-hold push-to-talk gesture
1803
+ * optimistically inserts before it recognizes the hold. Capped at the cursor column so it
1804
+ * never crosses a line boundary or under-runs the line. */
1805
+ deleteBeforeCursor(count: number): void {
1806
+ const removable = Math.min(count, this.#state.cursorCol);
1807
+ if (removable <= 0) return;
1808
+ this.#exitHistoryForEditing();
1809
+ this.#recordUndoState();
1810
+ const line = this.#state.lines[this.#state.cursorLine] ?? "";
1811
+ this.#state.lines[this.#state.cursorLine] =
1812
+ line.slice(0, this.#state.cursorCol - removable) + line.slice(this.#state.cursorCol);
1813
+ this.#setCursorCol(this.#state.cursorCol - removable);
1814
+ this.#lastAction = null;
1815
+ if (this.onChange) {
1816
+ this.onChange(this.getText());
1817
+ }
1818
+ }
1819
+
1820
+ /** Code units of the current volatile speech-to-text preview (see {@link setVolatileText}). */
1821
+ #volatileTextLen = 0;
1822
+
1823
+ /** Show or replace a volatile speech-to-text preview at the cursor. The text is
1824
+ * inserted with undo suspended so a long live dictation never floods the undo
1825
+ * stack; finalize it with {@link commitVolatileText} or drop it with
1826
+ * {@link clearVolatileText}. Newlines are allowed. */
1827
+ setVolatileText(text: string): void {
1828
+ this.#exitHistoryForEditing();
1829
+ this.#withUndoSuspended(() => {
1830
+ this.#deleteCharsBeforeCursor(this.#volatileTextLen);
1831
+ if (text) this.#insertTextAtCursor(text);
1832
+ });
1833
+ this.#volatileTextLen = text.length;
1834
+ if (!text && this.onChange) this.onChange(this.getText());
1835
+ }
1836
+
1837
+ /** Remove the current volatile preview without committing it. */
1838
+ clearVolatileText(): void {
1839
+ if (this.#volatileTextLen === 0) return;
1840
+ this.#withUndoSuspended(() => this.#deleteCharsBeforeCursor(this.#volatileTextLen));
1841
+ this.#volatileTextLen = 0;
1842
+ if (this.onChange) this.onChange(this.getText());
1843
+ }
1844
+
1845
+ /** Drop any volatile preview, then insert `text` as a single undoable edit. */
1846
+ commitVolatileText(text: string): void {
1847
+ this.#exitHistoryForEditing();
1848
+ this.#withUndoSuspended(() => this.#deleteCharsBeforeCursor(this.#volatileTextLen));
1849
+ this.#volatileTextLen = 0;
1850
+ if (text) this.#insertTextAtCursor(text);
1851
+ else if (this.onChange) this.onChange(this.getText());
1852
+ }
1853
+
1854
+ /** Delete `count` UTF-16 code units immediately before the cursor, crossing line
1855
+ * boundaries (each consumed newline counts as one). Undo is the caller's concern. */
1856
+ #deleteCharsBeforeCursor(count: number): void {
1857
+ let remaining = count;
1858
+ while (remaining > 0) {
1859
+ if (this.#state.cursorCol > 0) {
1860
+ const removable = Math.min(remaining, this.#state.cursorCol);
1861
+ const line = this.#state.lines[this.#state.cursorLine] ?? "";
1862
+ this.#state.lines[this.#state.cursorLine] =
1863
+ line.slice(0, this.#state.cursorCol - removable) + line.slice(this.#state.cursorCol);
1864
+ this.#setCursorCol(this.#state.cursorCol - removable);
1865
+ remaining -= removable;
1866
+ } else if (this.#state.cursorLine > 0) {
1867
+ const prev = this.#state.lines[this.#state.cursorLine - 1] ?? "";
1868
+ const cur = this.#state.lines[this.#state.cursorLine] ?? "";
1869
+ this.#state.lines[this.#state.cursorLine - 1] = prev + cur;
1870
+ this.#state.lines.splice(this.#state.cursorLine, 1);
1871
+ this.#state.cursorLine -= 1;
1872
+ this.#setCursorCol(prev.length);
1873
+ remaining -= 1;
1874
+ } else {
1875
+ break;
1876
+ }
1877
+ }
1878
+ }
1879
+
1880
+ /** Apply terminal paste semantics to text from non-bracketed paste transports. */
1881
+ pasteText(text: string): void {
1882
+ this.#handlePaste(text);
1883
+ }
1884
+
1885
+ /** Insert `content` as a collapsed `[Paste #N]` marker (stored for expansion on submit via
1886
+ * {@link getExpandedText}). Hosts that intercept large pastes through {@link onLargePaste} use
1887
+ * this to re-insert a (possibly transformed) paste without re-triggering the interception hook. */
1888
+ insertPaste(content: string): void {
1889
+ this.#historyIndex = -1;
1890
+ this.#resetKillSequence();
1891
+ this.#recordUndoState();
1892
+ this.#withUndoSuspended(() => {
1893
+ this.#storePasteMarker(content, content.split("\n").length);
1894
+ });
1895
+ }
1896
+
1897
+ // All the editor methods from before...
1898
+ #insertCharacter(char: string): void {
1899
+ this.#exitHistoryForEditing();
1900
+ // Undo coalescing: consecutive word typing collapses into one undo unit
1901
+ // (mirrors Input); any other action resets the run via #lastAction.
1902
+ const isWordChunk = [...segmenter.segment(char)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
1903
+ if (!isWordChunk || this.#lastAction !== "type-word") {
1904
+ this.#recordUndoState();
1905
+ }
1906
+ this.#lastAction = isWordChunk ? "type-word" : null;
1907
+
1908
+ const line = this.#state.lines[this.#state.cursorLine] || "";
1909
+
1910
+ const before = line.slice(0, this.#state.cursorCol);
1911
+ const after = line.slice(this.#state.cursorCol);
1912
+
1913
+ this.#state.lines[this.#state.cursorLine] = before + char + after;
1914
+ this.#setCursorCol(this.#state.cursorCol + char.length);
1915
+
1916
+ if (this.onChange) {
1917
+ this.onChange(this.getText());
1918
+ }
1919
+
1920
+ // Synchronous inline replacement (e.g. emoji shortcodes `:joy:` → 😂).
1921
+ // Runs before autocomplete trigger so the popup doesn't briefly chase a
1922
+ // prefix that's about to be rewritten.
1923
+ if (char.length === 1 && this.#autocompleteProvider?.trySyncInlineReplace) {
1924
+ const replaceLine = this.#state.lines[this.#state.cursorLine] || "";
1925
+ const textBeforeCursor = replaceLine.slice(0, this.#state.cursorCol);
1926
+ const replacement = this.#autocompleteProvider.trySyncInlineReplace(textBeforeCursor);
1927
+ if (replacement) {
1928
+ const before = replaceLine.slice(0, this.#state.cursorCol - replacement.replaceLen);
1929
+ const after = replaceLine.slice(this.#state.cursorCol);
1930
+ this.#state.lines[this.#state.cursorLine] = before + replacement.insert + after;
1931
+ this.#setCursorCol(before.length + replacement.insert.length);
1932
+ if (this.onChange) {
1933
+ this.onChange(this.getText());
1934
+ }
1935
+ if (this.#autocompleteState) {
1936
+ this.#cancelAutocomplete();
1937
+ this.onAutocompleteUpdate?.();
1938
+ }
1939
+ return;
1940
+ }
1941
+ }
1942
+
1943
+ // Check if we should trigger or update autocomplete
1944
+ if (!this.#autocompleteState) {
1945
+ // Auto-trigger for "/" at the start of a submitted command or a mid-prompt skill lookup.
1946
+ if (char === "/" && (this.#isAtStartOfSubmittedMessage() || this.#isInMidPromptSkillSlashContext())) {
1947
+ this.#tryTriggerAutocomplete();
1948
+ }
1949
+ // Auto-trigger for "@" file reference (fuzzy search)
1950
+ else if (char === "@") {
1951
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1952
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1953
+ // Only trigger if @ is after whitespace or at start of line
1954
+ const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
1955
+ if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
1956
+ this.#tryTriggerAutocomplete();
1957
+ }
1958
+ }
1959
+ // Auto-trigger for "#" prompt actions anywhere in the current token
1960
+ else if (char === "#") {
1961
+ this.#tryTriggerAutocomplete();
1962
+ }
1963
+ // Also auto-trigger when typing letters/path chars in a completable context
1964
+ else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
1965
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1966
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1967
+ // Check if we're in a slash command or mid-prompt skill lookup.
1968
+ if (this.#isInSlashAutocompleteContext()) {
1969
+ this.#tryTriggerAutocomplete();
1970
+ }
1971
+ // Check if we're in an @ file reference context
1972
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1973
+ this.#tryTriggerAutocomplete();
1974
+ }
1975
+ // Check if we're in a # prompt action context
1976
+ else if (textBeforeCursor.match(/#[^\s#]*$/)) {
1977
+ this.#tryTriggerAutocomplete();
1978
+ }
1979
+ // Check if we're in a :emoji shortcode context
1980
+ else if (textBeforeCursor.match(/(?:^|[\s([{>]):[a-zA-Z0-9_+-]*$/)) {
1981
+ this.#tryTriggerAutocomplete();
1982
+ }
1983
+ // Check if we're typing an internal URL scheme (e.g. local://, skill://)
1984
+ else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
1985
+ this.#tryTriggerAutocomplete();
1986
+ }
1987
+ }
1988
+ } else {
1989
+ this.#debouncedUpdateAutocomplete();
1990
+ }
1991
+ }
1992
+
1993
+ #handlePaste(pastedText: string): void {
1994
+ let filteredText = this.#sanitizePastedText(pastedText);
1995
+
1996
+ // If pasting a file path (starts with /, ~, or .) and the character before
1997
+ // the cursor is a word character, prepend a space for better readability.
1998
+ if (/^[/~.]/.test(filteredText)) {
1999
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2000
+ const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
2001
+ if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
2002
+ filteredText = ` ${filteredText}`;
2003
+ }
2004
+ }
2005
+
2006
+ const pastedLines = filteredText.split("\n");
2007
+ const totalChars = filteredText.length;
2008
+ // "Marker-sized": large enough to collapse into a `[Paste #N]` token (> 10 lines or
2009
+ // > 1000 characters) instead of flooding the buffer.
2010
+ const isMarkerSized = pastedLines.length > 10 || totalChars > 1000;
2011
+
2012
+ // Let the host intercept marker-sized pastes (e.g. the large-paste menu). When it takes
2013
+ // over, the editor inserts nothing and records no undo state — the host re-inserts via
2014
+ // `insertPaste`/`insertText` once the user chooses.
2015
+ if (isMarkerSized && this.onLargePaste?.(filteredText, pastedLines.length)) {
2016
+ return;
2017
+ }
2018
+
2019
+ this.#historyIndex = -1; // Exit history browsing mode
2020
+ this.#resetKillSequence();
2021
+ this.#recordUndoState();
2022
+
2023
+ this.#withUndoSuspended(() => {
2024
+ if (isMarkerSized) {
2025
+ this.#storePasteMarker(filteredText, pastedLines.length);
2026
+ return;
2027
+ }
2028
+
2029
+ if (pastedLines.length === 1) {
2030
+ // Single line - insert in one operation (per-char replay is O(paste × buffer)),
2031
+ // then evaluate autocomplete triggers once at the final cursor position.
2032
+ if (filteredText) {
2033
+ this.#insertTextAtCursor(filteredText);
2034
+ }
2035
+ return;
2036
+ }
2037
+
2038
+ // Multi-line paste - use insertTextAtCursor for proper handling
2039
+ this.#insertTextAtCursor(filteredText);
2040
+ });
2041
+ }
2042
+
2043
+ /** Normalize raw pasted text: decode tmux re-encoded control bytes (both extended-keys formats),
2044
+ * normalize CRLF and
2045
+ * NFC (macOS NFD filename drag-drops), expand tabs, and strip control characters except newline. */
2046
+ #sanitizePastedText(pastedText: string): string {
2047
+ // Decode tmux's re-encoded control bytes (both extended-keys formats) back to
2048
+ // their literal byte so the per-char filter below preserves newlines instead of
2049
+ // stripping ESC and leaking the printable tail into the editor. See the decoder.
2050
+ const decodedText = decodeReencodedPasteControls(pastedText);
2051
+
2052
+ // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
2053
+ // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
2054
+ // land in the buffer as the same precomposed syllables a terminal
2055
+ // renders — without this, cursor column accounting drifts by
2056
+ // `(NFD cells − NFC cells)` and the visible glyph desyncs from the
2057
+ // hardware cursor.
2058
+ const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
2059
+
2060
+ // Convert tabs to spaces (4 spaces per tab).
2061
+ const tabExpandedText = cleanText.replace(/\t/g, " ");
2062
+
2063
+ // Strip control characters except newline (tabs already expanded above, CRs already
2064
+ // normalized). Single regex pass instead of split/filter/join to avoid allocating a
2065
+ // per-code-unit array for large pastes.
2066
+ return tabExpandedText.replace(/[\x00-\x09\x0B-\x1F]/g, "");
2067
+ }
2068
+
2069
+ /** Store `content` in the paste buffer and insert a collapsed `[Paste #N]` marker that expands
2070
+ * back to `content` on submit. `lineCount` is the content's line count. */
2071
+ #storePasteMarker(content: string, lineCount: number): void {
2072
+ this.#pasteCounter++;
2073
+ const pasteId = this.#pasteCounter;
2074
+ this.#pastes.set(pasteId, content);
2075
+
2076
+ // Insert marker like "[Paste #1, +123 lines]" or "[Paste #1, 1234 chars]".
2077
+ const marker =
2078
+ lineCount > 10 ? `[Paste #${pasteId}, +${lineCount} lines]` : `[Paste #${pasteId}, ${content.length} chars]`;
2079
+ this.#insertTextAtCursor(marker);
2080
+ }
2081
+
2082
+ /** Re-evaluate autocomplete triggers for the text ending at the cursor (used after bulk edits). */
2083
+ #retriggerAutocompleteAtCursor(): void {
2084
+ if (this.#autocompleteState) {
2085
+ this.#debouncedUpdateAutocomplete();
2086
+ return;
2087
+ }
2088
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2089
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2090
+ if (this.#isInSlashAutocompleteContext()) {
2091
+ this.#tryTriggerAutocomplete();
2092
+ } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
2093
+ this.#tryTriggerAutocomplete();
2094
+ } else if (textBeforeCursor.match(/#[^\s#]*$/)) {
2095
+ this.#tryTriggerAutocomplete();
2096
+ } else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
2097
+ this.#tryTriggerAutocomplete();
2098
+ }
2099
+ }
2100
+
2101
+ #addNewLine(): void {
2102
+ this.#historyIndex = -1; // Exit history browsing mode
2103
+ this.#resetKillSequence();
2104
+ this.#recordUndoState();
2105
+
2106
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2107
+
2108
+ const before = currentLine.slice(0, this.#state.cursorCol);
2109
+ const after = currentLine.slice(this.#state.cursorCol);
2110
+
2111
+ // Split current line
2112
+ this.#state.lines[this.#state.cursorLine] = before;
2113
+ this.#state.lines.splice(this.#state.cursorLine + 1, 0, after);
2114
+
2115
+ // Move cursor to start of new line
2116
+ this.#state.cursorLine++;
2117
+ this.#setCursorCol(0);
2118
+
2119
+ if (this.onChange) {
2120
+ this.onChange(this.getText());
2121
+ }
2122
+ }
2123
+
2124
+ #shouldSubmitOnBackslashEnter(data: string, kb: KeybindingsManager): boolean {
2125
+ if (this.disableSubmit) return false;
2126
+ if (!matchesKey(data, "enter")) return false;
2127
+ const submitKeys = kb.getKeys("tui.input.submit");
2128
+ const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
2129
+ if (!hasShiftEnter) return false;
2130
+
2131
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2132
+ return this.#state.cursorCol > 0 && currentLine[this.#state.cursorCol - 1] === "\\";
2133
+ }
2134
+
2135
+ #submitValue(): void {
2136
+ this.#resetKillSequence();
2137
+
2138
+ const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
2139
+
2140
+ this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
2141
+ this.#pastes.clear();
2142
+ this.#pasteCounter = 0;
2143
+ this.#historyIndex = -1;
2144
+ this.#scrollOffset = 0;
2145
+ this.#undoStack.length = 0;
2146
+
2147
+ if (this.onChange) this.onChange("");
2148
+ if (this.onSubmit) this.onSubmit(result);
2149
+ }
2150
+
2151
+ /** Resolve the compiled, global copy of `atomicTokenPattern`, rebuilt only when the source changes. */
2152
+ #getAtomicTokenRe(): RegExp | undefined {
2153
+ const pattern = this.atomicTokenPattern;
2154
+ if (pattern === undefined) {
2155
+ this.#atomicTokenSource = undefined;
2156
+ this.#atomicTokenRe = undefined;
2157
+ return undefined;
2158
+ }
2159
+ if (pattern.source !== this.#atomicTokenSource) {
2160
+ this.#atomicTokenSource = pattern.source;
2161
+ this.#atomicTokenRe = new RegExp(
2162
+ pattern.source,
2163
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
2164
+ );
2165
+ }
2166
+ return this.#atomicTokenRe;
2167
+ }
2168
+
2169
+ /** Find an atomic token on `line` whose span contains column `col` (`start <= col < end`). */
2170
+ #atomicTokenAt(line: string, col: number): { start: number; end: number } | undefined {
2171
+ const re = this.#getAtomicTokenRe();
2172
+ if (re === undefined) return undefined;
2173
+ re.lastIndex = 0;
2174
+ for (;;) {
2175
+ const match = re.exec(line);
2176
+ if (match === null) break;
2177
+ if (match[0].length === 0) {
2178
+ re.lastIndex = match.index + 1;
2179
+ continue;
2180
+ }
2181
+ const start = match.index;
2182
+ const end = start + match[0].length;
2183
+ if (col < start) break;
2184
+ if (col < end) return { start, end };
2185
+ }
2186
+ return undefined;
2187
+ }
2188
+
2189
+ /** Expand the half-open range [start, end) so it never cuts through an atomic
2190
+ * placeholder token: a boundary landing inside a token pulls the whole token in. */
2191
+ #expandRangeOverAtomicTokens(line: string, start: number, end: number): { start: number; end: number } {
2192
+ const startToken = this.#atomicTokenAt(line, start);
2193
+ if (startToken !== undefined && startToken.start < start) {
2194
+ start = startToken.start;
2195
+ }
2196
+ if (end > start) {
2197
+ const endToken = this.#atomicTokenAt(line, end - 1);
2198
+ if (endToken !== undefined && endToken.end > end) {
2199
+ end = endToken.end;
2200
+ }
2201
+ }
2202
+ return { start, end };
2203
+ }
2204
+
2205
+ #handleBackspace(): void {
2206
+ this.#historyIndex = -1; // Exit history browsing mode
2207
+ this.#resetKillSequence();
2208
+ this.#recordUndoState();
2209
+
2210
+ let removedSlashTrigger = false;
2211
+
2212
+ if (this.#state.cursorCol > 0) {
2213
+ const line = this.#state.lines[this.#state.cursorLine] || "";
2214
+ const textBeforeCursor = line.slice(0, this.#state.cursorCol);
2215
+ const trailingSlashStart = findTrailingSlashCommandStart(textBeforeCursor);
2216
+ removedSlashTrigger = trailingSlashStart === this.#state.cursorCol - 1;
2217
+ // An atomic placeholder token (image/paste marker) deletes as a unit, so a single
2218
+ // backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text.
2219
+ const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1);
2220
+ if (token !== undefined) {
2221
+ this.#state.lines[this.#state.cursorLine] = line.slice(0, token.start) + line.slice(token.end);
2222
+ this.#setCursorCol(token.start);
2223
+ } else {
2224
+ // Delete grapheme before cursor (handles emojis, combining characters, etc.)
2225
+ const beforeCursor = line.slice(0, this.#state.cursorCol);
2226
+
2227
+ // Find the last grapheme in the text before cursor
2228
+ const graphemes = [...segmenter.segment(beforeCursor)];
2229
+ const lastGrapheme = graphemes[graphemes.length - 1];
2230
+ const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
2231
+
2232
+ const before = line.slice(0, this.#state.cursorCol - graphemeLength);
2233
+ const after = line.slice(this.#state.cursorCol);
2234
+
2235
+ this.#state.lines[this.#state.cursorLine] = before + after;
2236
+ this.#setCursorCol(this.#state.cursorCol - graphemeLength);
2237
+ }
2238
+ } else if (this.#state.cursorLine > 0) {
2239
+ // Merge with previous line
2240
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2241
+ const previousLine = this.#state.lines[this.#state.cursorLine - 1] || "";
2242
+
2243
+ this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine;
2244
+ this.#state.lines.splice(this.#state.cursorLine, 1);
2245
+
2246
+ this.#state.cursorLine--;
2247
+ this.#setCursorCol(previousLine.length);
2248
+ }
2249
+
2250
+ if (this.onChange) {
2251
+ this.onChange(this.getText());
2252
+ }
2253
+
2254
+ // Update or re-trigger autocomplete after backspace
2255
+ if (this.#autocompleteState) {
2256
+ if (removedSlashTrigger) {
2257
+ this.#cancelAutocomplete();
2258
+ this.onAutocompleteUpdate?.();
2259
+ } else {
2260
+ this.#debouncedUpdateAutocomplete();
2261
+ }
2262
+ } else {
2263
+ // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
2264
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2265
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2266
+ // Slash command or mid-prompt skill lookup context
2267
+ if (this.#isInSlashAutocompleteContext()) {
2268
+ this.#tryTriggerAutocomplete();
2269
+ }
2270
+ // @ file reference context
2271
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
2272
+ this.#tryTriggerAutocomplete();
2273
+ }
2274
+ // # prompt action context
2275
+ else if (textBeforeCursor.match(/#[^\s#]*$/)) {
2276
+ this.#tryTriggerAutocomplete();
2277
+ }
2278
+ // internal URL scheme context (e.g. local://, skill://)
2279
+ else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
2280
+ this.#tryTriggerAutocomplete();
2281
+ }
2282
+ }
2283
+ }
2284
+
2285
+ /**
2286
+ * Set cursor column and clear preferredVisualCol.
2287
+ * Use this for all non-vertical cursor movements to reset sticky column behavior.
2288
+ */
2289
+ #setCursorCol(col: number): void {
2290
+ this.#state.cursorCol = col;
2291
+ this.#preferredVisualCol = null;
2292
+ }
2293
+
2294
+ /**
2295
+ * Move cursor to a target visual line, applying sticky column logic.
2296
+ * Shared by moveCursor() and pageScroll().
2297
+ */
2298
+ #moveToVisualLine(
2299
+ visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
2300
+ currentVisualLine: number,
2301
+ targetVisualLine: number,
2302
+ ): void {
2303
+ const currentVL = visualLines[currentVisualLine];
2304
+ const targetVL = visualLines[targetVisualLine];
2305
+
2306
+ if (currentVL && targetVL) {
2307
+ // Work in visual cells (grapheme-walked), not UTF-16 code units: code-unit
2308
+ // columns land mid-surrogate on emoji and drift on wide CJK glyphs.
2309
+ const sourceLine = this.#state.lines[currentVL.logicalLine] || "";
2310
+ const sourceText = sourceLine.slice(currentVL.startCol, currentVL.startCol + currentVL.length);
2311
+ const currentVisualCol = visualColAtOffset(sourceText, this.#state.cursorCol - currentVL.startCol);
2312
+
2313
+ // For non-last segments, clamp before the segment end to stay within the segment
2314
+ const isLastSourceSegment =
2315
+ currentVisualLine === visualLines.length - 1 ||
2316
+ visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
2317
+ const sourceMaxVisualCol = maxSegmentVisualCol(sourceText, isLastSourceSegment);
2318
+
2319
+ const isLastTargetSegment =
2320
+ targetVisualLine === visualLines.length - 1 ||
2321
+ visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
2322
+ const targetLine = this.#state.lines[targetVL.logicalLine] || "";
2323
+ const targetText = targetLine.slice(targetVL.startCol, targetVL.startCol + targetVL.length);
2324
+ const targetMaxVisualCol = maxSegmentVisualCol(targetText, isLastTargetSegment);
2325
+
2326
+ const moveToVisualCol = this.#computeVerticalMoveColumn(
2327
+ currentVisualCol,
2328
+ sourceMaxVisualCol,
2329
+ targetMaxVisualCol,
2330
+ );
2331
+
2332
+ // Set cursor position, snapping to a grapheme boundary in the target text
2333
+ this.#state.cursorLine = targetVL.logicalLine;
2334
+ const targetCol = targetVL.startCol + offsetAtVisualCol(targetText, moveToVisualCol);
2335
+ this.#state.cursorCol = Math.min(targetCol, targetLine.length);
2336
+ }
2337
+ }
2338
+
2339
+ /**
2340
+ * Compute the target visual column for vertical cursor movement.
2341
+ * Implements the sticky column decision table.
2342
+ */
2343
+ #computeVerticalMoveColumn(
2344
+ currentVisualCol: number,
2345
+ sourceMaxVisualCol: number,
2346
+ targetMaxVisualCol: number,
2347
+ ): number {
2348
+ const hasPreferred = this.#preferredVisualCol !== null;
2349
+ const cursorInMiddle = currentVisualCol < sourceMaxVisualCol;
2350
+ const targetTooShort = targetMaxVisualCol < currentVisualCol;
2351
+
2352
+ if (!hasPreferred || cursorInMiddle) {
2353
+ if (targetTooShort) {
2354
+ this.#preferredVisualCol = currentVisualCol;
2355
+ return targetMaxVisualCol;
2356
+ }
2357
+ this.#preferredVisualCol = null;
2358
+ return currentVisualCol;
2359
+ }
2360
+
2361
+ const targetCantFitPreferred = targetMaxVisualCol < this.#preferredVisualCol!;
2362
+ if (targetTooShort || targetCantFitPreferred) {
2363
+ return targetMaxVisualCol;
2364
+ }
2365
+
2366
+ const result = this.#preferredVisualCol!;
2367
+ this.#preferredVisualCol = null;
2368
+ return result;
2369
+ }
2370
+
2371
+ #moveToLineStart(): void {
2372
+ this.#resetKillSequence();
2373
+ this.#setCursorCol(0);
2374
+ }
2375
+
2376
+ #moveToLineEnd(): void {
2377
+ this.#resetKillSequence();
2378
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2379
+ this.#setCursorCol(currentLine.length);
2380
+ }
2381
+
2382
+ #moveToMessageStart(): void {
2383
+ this.#resetKillSequence();
2384
+ this.#state.cursorLine = 0;
2385
+ this.#setCursorCol(0);
2386
+ }
2387
+
2388
+ #moveToMessageEnd(): void {
2389
+ this.#resetKillSequence();
2390
+ this.#state.cursorLine = this.#state.lines.length - 1;
2391
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2392
+ this.#setCursorCol(currentLine.length);
2393
+ }
2394
+
2395
+ #resetKillSequence(): void {
2396
+ this.#lastAction = null;
2397
+ }
2398
+
2399
+ #withUndoSuspended<T>(fn: () => T): T {
2400
+ const wasSuspended = this.#suspendUndo;
2401
+ this.#suspendUndo = true;
2402
+ try {
2403
+ return fn();
2404
+ } finally {
2405
+ this.#suspendUndo = wasSuspended;
2406
+ }
2407
+ }
2408
+
2409
+ #recordUndoState(): void {
2410
+ if (this.#suspendUndo) return;
2411
+ this.#undoStack.push(structuredClone(this.#state));
2412
+ if (this.#undoStack.length > MAX_UNDO_STACK) {
2413
+ this.#undoStack.shift();
2414
+ }
2415
+ }
2416
+
2417
+ #applyUndo(): void {
2418
+ const snapshot = this.#undoStack.pop();
2419
+ if (!snapshot) return;
2420
+
2421
+ this.#historyIndex = -1;
2422
+ this.#resetKillSequence();
2423
+ this.#preferredVisualCol = null;
2424
+ Object.assign(this.#state, snapshot);
2425
+
2426
+ if (this.onChange) {
2427
+ this.onChange(this.getText());
2428
+ }
2429
+
2430
+ if (this.#autocompleteState) {
2431
+ this.#debouncedUpdateAutocomplete();
2432
+ } else {
2433
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2434
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2435
+ if (this.#isInSlashAutocompleteContext()) {
2436
+ this.#tryTriggerAutocomplete();
2437
+ } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
2438
+ this.#tryTriggerAutocomplete();
2439
+ } else if (textBeforeCursor.match(/#[^\s#]*$/)) {
2440
+ this.#tryTriggerAutocomplete();
2441
+ } else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
2442
+ this.#tryTriggerAutocomplete();
2443
+ }
2444
+ }
2445
+ }
2446
+
2447
+ #matchesTransientUndoSnapshot(
2448
+ snapshot: EditorState,
2449
+ transientText: string,
2450
+ transientStartCol: number,
2451
+ beforeTransient: string,
2452
+ afterTransient: string,
2453
+ ): boolean {
2454
+ if (snapshot.cursorLine !== this.#state.cursorLine) return false;
2455
+ if (snapshot.lines.length !== this.#state.lines.length) return false;
2456
+
2457
+ const transientLength = snapshot.cursorCol - transientStartCol;
2458
+ if (transientLength < 0 || transientLength >= transientText.length) return false;
2459
+
2460
+ for (let i = 0; i < snapshot.lines.length; i++) {
2461
+ if (i === this.#state.cursorLine) continue;
2462
+ if (snapshot.lines[i] !== this.#state.lines[i]) return false;
2463
+ }
2464
+
2465
+ return (
2466
+ snapshot.lines[snapshot.cursorLine] ===
2467
+ beforeTransient + transientText.slice(0, transientLength) + afterTransient
2468
+ );
2469
+ }
2470
+
2471
+ #recordKill(text: string, direction: "forward" | "backward", accumulate = this.#lastAction === "kill"): void {
2472
+ if (!text) return;
2473
+ this.#killRing.push(text, { prepend: direction === "backward", accumulate });
2474
+ this.#lastAction = "kill";
2475
+ }
2476
+
2477
+ #insertTextAtCursor(text: string): void {
2478
+ this.#historyIndex = -1;
2479
+ this.#resetKillSequence();
2480
+ this.#recordUndoState();
2481
+
2482
+ const normalized = text.replace(/\r\n?/g, "\n");
2483
+ const lines = normalized.split("\n");
2484
+
2485
+ if (lines.length === 1) {
2486
+ const line = this.#state.lines[this.#state.cursorLine] || "";
2487
+ const before = line.slice(0, this.#state.cursorCol);
2488
+ const after = line.slice(this.#state.cursorCol);
2489
+ this.#state.lines[this.#state.cursorLine] = before + normalized + after;
2490
+ this.#setCursorCol(this.#state.cursorCol + normalized.length);
2491
+ } else {
2492
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2493
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2494
+ const afterCursor = currentLine.slice(this.#state.cursorCol);
2495
+
2496
+ const newLines: string[] = [];
2497
+ for (let i = 0; i < this.#state.cursorLine; i++) {
2498
+ newLines.push(this.#state.lines[i] || "");
2499
+ }
2500
+
2501
+ newLines.push(beforeCursor + (lines[0] || ""));
2502
+ for (let i = 1; i < lines.length - 1; i++) {
2503
+ newLines.push(lines[i] || "");
2504
+ }
2505
+ newLines.push((lines[lines.length - 1] || "") + afterCursor);
2506
+
2507
+ for (let i = this.#state.cursorLine + 1; i < this.#state.lines.length; i++) {
2508
+ newLines.push(this.#state.lines[i] || "");
2509
+ }
2510
+
2511
+ this.#state.lines = newLines;
2512
+ this.#state.cursorLine += lines.length - 1;
2513
+ this.#setCursorCol((lines[lines.length - 1] || "").length);
2514
+ }
2515
+
2516
+ if (this.onChange) {
2517
+ this.onChange(this.getText());
2518
+ }
2519
+ this.#retriggerAutocompleteAtCursor();
2520
+ }
2521
+
2522
+ #yankFromKillRing(): void {
2523
+ const text = this.#killRing.peek();
2524
+ if (!text) return;
2525
+ this.#insertTextAtCursor(text);
2526
+ this.#lastAction = "yank";
2527
+ }
2528
+
2529
+ #yankPop(): void {
2530
+ if (this.#lastAction !== "yank") return;
2531
+ if (this.#killRing.length <= 1) return;
2532
+
2533
+ this.#historyIndex = -1;
2534
+ this.#recordUndoState();
2535
+
2536
+ this.#withUndoSuspended(() => {
2537
+ if (!this.#deleteYankedText()) return;
2538
+ this.#killRing.rotate();
2539
+ const text = this.#killRing.peek();
2540
+ if (text) {
2541
+ this.#insertTextAtCursor(text);
2542
+ }
2543
+ });
2544
+
2545
+ this.#lastAction = "yank";
2546
+ }
2547
+
2548
+ /**
2549
+ * Delete the most recently yanked text from the buffer.
2550
+ *
2551
+ * This is a best-effort operation and assumes the cursor is still positioned
2552
+ * at the end of the yanked text.
2553
+ */
2554
+ #deleteYankedText(): boolean {
2555
+ const yankedText = this.#killRing.peek();
2556
+ if (!yankedText) return false;
2557
+
2558
+ const yankLines = yankedText.split("\n");
2559
+ const endLine = this.#state.cursorLine;
2560
+ const endCol = this.#state.cursorCol;
2561
+ const startLine = endLine - (yankLines.length - 1);
2562
+ if (startLine < 0) return false;
2563
+
2564
+ if (yankLines.length === 1) {
2565
+ const line = this.#state.lines[endLine] ?? "";
2566
+ const startCol = endCol - yankedText.length;
2567
+ if (startCol < 0) return false;
2568
+ if (line.slice(startCol, endCol) !== yankedText) return false;
2569
+
2570
+ this.#state.lines[endLine] = line.slice(0, startCol) + line.slice(endCol);
2571
+ this.#state.cursorLine = endLine;
2572
+ this.#setCursorCol(startCol);
2573
+ return true;
2574
+ }
2575
+
2576
+ const firstInserted = yankLines[0] ?? "";
2577
+ const lastInserted = yankLines[yankLines.length - 1] ?? "";
2578
+ const firstLineText = this.#state.lines[startLine] ?? "";
2579
+ const lastLineText = this.#state.lines[endLine] ?? "";
2580
+
2581
+ if (!firstLineText.endsWith(firstInserted)) return false;
2582
+ if (endCol !== lastInserted.length) return false;
2583
+ if (lastLineText.slice(0, endCol) !== lastInserted) return false;
2584
+
2585
+ const startCol = firstLineText.length - firstInserted.length;
2586
+ if (startCol < 0) return false;
2587
+
2588
+ const suffix = lastLineText.slice(endCol);
2589
+ const newLine = firstLineText.slice(0, startCol) + suffix;
2590
+
2591
+ this.#state.lines.splice(startLine, yankLines.length, newLine);
2592
+ this.#state.cursorLine = startLine;
2593
+ this.#setCursorCol(startCol);
2594
+ return true;
2595
+ }
2596
+
2597
+ #deleteToStartOfLine(): void {
2598
+ this.#historyIndex = -1; // Exit history browsing mode
2599
+ this.#recordUndoState();
2600
+
2601
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2602
+ let deletedText = "";
2603
+
2604
+ if (this.#state.cursorCol > 0) {
2605
+ // Delete from start of line up to cursor, extending over any atomic token
2606
+ // the boundary would otherwise cut in half.
2607
+ const { end } = this.#expandRangeOverAtomicTokens(currentLine, 0, this.#state.cursorCol);
2608
+ deletedText = currentLine.slice(0, end);
2609
+ this.#state.lines[this.#state.cursorLine] = currentLine.slice(end);
2610
+ this.#setCursorCol(0);
2611
+ } else if (this.#state.cursorLine > 0) {
2612
+ // At start of line - merge with previous line
2613
+ deletedText = "\n";
2614
+ const previousLine = this.#state.lines[this.#state.cursorLine - 1] || "";
2615
+ this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine;
2616
+ this.#state.lines.splice(this.#state.cursorLine, 1);
2617
+ this.#state.cursorLine--;
2618
+ this.#setCursorCol(previousLine.length);
2619
+ }
2620
+
2621
+ this.#recordKill(deletedText, "backward");
2622
+
2623
+ if (this.onChange) {
2624
+ this.onChange(this.getText());
2625
+ }
2626
+ this.#retriggerAutocompleteAtCursor();
2627
+ }
2628
+
2629
+ #deleteToEndOfLine(): void {
2630
+ this.#historyIndex = -1; // Exit history browsing mode
2631
+ this.#recordUndoState();
2632
+
2633
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2634
+ let deletedText = "";
2635
+
2636
+ if (this.#state.cursorCol < currentLine.length) {
2637
+ // Delete from cursor to end of line, extending backwards over an atomic
2638
+ // token the cursor sits inside so no half-eaten marker text remains.
2639
+ const { start } = this.#expandRangeOverAtomicTokens(currentLine, this.#state.cursorCol, currentLine.length);
2640
+ deletedText = currentLine.slice(start);
2641
+ this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, start);
2642
+ if (start < this.#state.cursorCol) {
2643
+ this.#setCursorCol(start);
2644
+ }
2645
+ } else if (this.#state.cursorLine < this.#state.lines.length - 1) {
2646
+ // At end of line - merge with next line
2647
+ const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
2648
+ deletedText = "\n";
2649
+ this.#state.lines[this.#state.cursorLine] = currentLine + nextLine;
2650
+ this.#state.lines.splice(this.#state.cursorLine + 1, 1);
2651
+ }
2652
+
2653
+ this.#recordKill(deletedText, "forward");
2654
+
2655
+ if (this.onChange) {
2656
+ this.onChange(this.getText());
2657
+ }
2658
+ this.#retriggerAutocompleteAtCursor();
2659
+ }
2660
+
2661
+ #deleteWordBackwards(): void {
2662
+ this.#historyIndex = -1; // Exit history browsing mode
2663
+ this.#recordUndoState();
2664
+
2665
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2666
+
2667
+ // If at start of line, behave like backspace at column 0 (merge with previous line)
2668
+ if (this.#state.cursorCol === 0) {
2669
+ if (this.#state.cursorLine > 0) {
2670
+ this.#recordKill("\n", "backward");
2671
+ const previousLine = this.#state.lines[this.#state.cursorLine - 1] || "";
2672
+ this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine;
2673
+ this.#state.lines.splice(this.#state.cursorLine, 1);
2674
+ this.#state.cursorLine--;
2675
+ this.#setCursorCol(previousLine.length);
2676
+ }
2677
+ } else {
2678
+ const oldCursorCol = this.#state.cursorCol;
2679
+ this.#moveWordBackwards();
2680
+ // Extend the range over any atomic token it intersects so a word delete
2681
+ // never leaves half-eaten marker text behind.
2682
+ const range = this.#expandRangeOverAtomicTokens(currentLine, this.#state.cursorCol, oldCursorCol);
2683
+
2684
+ const deletedText = currentLine.slice(range.start, range.end);
2685
+ this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, range.start) + currentLine.slice(range.end);
2686
+ this.#setCursorCol(range.start);
2687
+ this.#recordKill(deletedText, "backward");
2688
+ }
2689
+
2690
+ if (this.onChange) {
2691
+ this.onChange(this.getText());
2692
+ }
2693
+ this.#retriggerAutocompleteAtCursor();
2694
+ }
2695
+
2696
+ #deleteWordForwards(): void {
2697
+ this.#historyIndex = -1; // Exit history browsing mode
2698
+ this.#recordUndoState();
2699
+
2700
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2701
+
2702
+ if (this.#state.cursorCol >= currentLine.length) {
2703
+ if (this.#state.cursorLine < this.#state.lines.length - 1) {
2704
+ this.#recordKill("\n", "forward");
2705
+ const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
2706
+ this.#state.lines[this.#state.cursorLine] = currentLine + nextLine;
2707
+ this.#state.lines.splice(this.#state.cursorLine + 1, 1);
2708
+ }
2709
+ } else {
2710
+ const oldCursorCol = this.#state.cursorCol;
2711
+ this.#moveWordForwards();
2712
+ // Extend the range over any atomic token it intersects so a word delete
2713
+ // never leaves half-eaten marker text behind.
2714
+ const range = this.#expandRangeOverAtomicTokens(currentLine, oldCursorCol, this.#state.cursorCol);
2715
+
2716
+ const deletedText = currentLine.slice(range.start, range.end);
2717
+ this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, range.start) + currentLine.slice(range.end);
2718
+ this.#setCursorCol(range.start);
2719
+ this.#recordKill(deletedText, "forward");
2720
+ }
2721
+
2722
+ if (this.onChange) {
2723
+ this.onChange(this.getText());
2724
+ }
2725
+ this.#retriggerAutocompleteAtCursor();
2726
+ }
2727
+
2728
+ #handleForwardDelete(): void {
2729
+ this.#historyIndex = -1; // Exit history browsing mode
2730
+ this.#resetKillSequence();
2731
+ this.#recordUndoState();
2732
+
2733
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2734
+
2735
+ if (this.#state.cursorCol < currentLine.length) {
2736
+ // An atomic placeholder token (image/paste marker) deletes as a unit.
2737
+ const token = this.#atomicTokenAt(currentLine, this.#state.cursorCol);
2738
+ if (token !== undefined) {
2739
+ this.#state.lines[this.#state.cursorLine] =
2740
+ currentLine.slice(0, token.start) + currentLine.slice(token.end);
2741
+ this.#setCursorCol(token.start);
2742
+ } else {
2743
+ // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
2744
+ const afterCursor = currentLine.slice(this.#state.cursorCol);
2745
+
2746
+ // Find the first grapheme at cursor
2747
+ const graphemes = [...segmenter.segment(afterCursor)];
2748
+ const firstGrapheme = graphemes[0];
2749
+ const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
2750
+
2751
+ const before = currentLine.slice(0, this.#state.cursorCol);
2752
+ const after = currentLine.slice(this.#state.cursorCol + graphemeLength);
2753
+ this.#state.lines[this.#state.cursorLine] = before + after;
2754
+ }
2755
+ } else if (this.#state.cursorLine < this.#state.lines.length - 1) {
2756
+ // At end of line - merge with next line
2757
+ const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
2758
+ this.#state.lines[this.#state.cursorLine] = currentLine + nextLine;
2759
+ this.#state.lines.splice(this.#state.cursorLine + 1, 1);
2760
+ }
2761
+
2762
+ if (this.onChange) {
2763
+ this.onChange(this.getText());
2764
+ }
2765
+
2766
+ // Update or re-trigger autocomplete after forward delete
2767
+ if (this.#autocompleteState) {
2768
+ this.#debouncedUpdateAutocomplete();
2769
+ } else {
2770
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2771
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2772
+ // Slash command or mid-prompt skill lookup context
2773
+ if (this.#isInSlashAutocompleteContext()) {
2774
+ this.#tryTriggerAutocomplete();
2775
+ }
2776
+ // @ file reference context
2777
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
2778
+ this.#tryTriggerAutocomplete();
2779
+ }
2780
+ // # prompt action context
2781
+ else if (textBeforeCursor.match(/#[^\s#]*$/)) {
2782
+ this.#tryTriggerAutocomplete();
2783
+ }
2784
+ // internal URL scheme context (e.g. local://, skill://)
2785
+ else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
2786
+ this.#tryTriggerAutocomplete();
2787
+ }
2788
+ }
2789
+ }
2790
+
2791
+ /**
2792
+ * Build a mapping from visual lines to logical positions.
2793
+ * Returns an array where each element represents a visual line with:
2794
+ * - logicalLine: index into this.#state.lines
2795
+ * - startCol: starting column in the logical line
2796
+ * - length: length of this visual line segment
2797
+ */
2798
+ #buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> {
2799
+ const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = [];
2800
+
2801
+ for (let i = 0; i < this.#state.lines.length; i++) {
2802
+ const line = this.#state.lines[i] || "";
2803
+ const lineVisWidth = this.#lineEntry(line, width).width;
2804
+ if (line.length === 0) {
2805
+ // Empty line still takes one visual line
2806
+ visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
2807
+ } else if (lineVisWidth <= width) {
2808
+ visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
2809
+ } else {
2810
+ // Line needs wrapping - use word-aware wrapping
2811
+ const chunks = this.#wrapLine(line, width);
2812
+ for (const chunk of chunks) {
2813
+ visualLines.push({
2814
+ logicalLine: i,
2815
+ startCol: chunk.startIndex,
2816
+ length: chunk.endIndex - chunk.startIndex,
2817
+ });
2818
+ }
2819
+ }
2820
+ }
2821
+
2822
+ return visualLines;
2823
+ }
2824
+
2825
+ /**
2826
+ * Find the visual line index for the current cursor position.
2827
+ */
2828
+ #findCurrentVisualLine(visualLines: Array<{ logicalLine: number; startCol: number; length: number }>): number {
2829
+ for (let i = 0; i < visualLines.length; i++) {
2830
+ const vl = visualLines[i];
2831
+ if (!vl) continue;
2832
+ if (vl.logicalLine === this.#state.cursorLine) {
2833
+ const colInSegment = this.#state.cursorCol - vl.startCol;
2834
+ // Cursor is in this segment if it's within range
2835
+ // For the last segment of a logical line, cursor can be at length (end position)
2836
+ // The first segment also owns any leading whitespace the wrapper skipped
2837
+ // (its startCol can be > 0), so a negative colInSegment maps there.
2838
+ const isLastSegmentOfLine =
2839
+ i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
2840
+ const isFirstSegmentOfLine = i === 0 || visualLines[i - 1]?.logicalLine !== vl.logicalLine;
2841
+ if (
2842
+ (colInSegment >= 0 || isFirstSegmentOfLine) &&
2843
+ (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))
2844
+ ) {
2845
+ return i;
2846
+ }
2847
+ }
2848
+ }
2849
+ // Fallback: return last visual line
2850
+ return visualLines.length - 1;
2851
+ }
2852
+
2853
+ #moveCursor(deltaLine: number, deltaCol: number): void {
2854
+ this.#resetKillSequence();
2855
+ const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
2856
+ const currentVisualLine = this.#findCurrentVisualLine(visualLines);
2857
+
2858
+ if (deltaLine !== 0) {
2859
+ const targetVisualLine = currentVisualLine + deltaLine;
2860
+
2861
+ if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
2862
+ this.#moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
2863
+ }
2864
+ }
2865
+
2866
+ if (deltaCol !== 0) {
2867
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2868
+
2869
+ if (deltaCol > 0) {
2870
+ // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
2871
+ if (this.#state.cursorCol < currentLine.length) {
2872
+ const afterCursor = currentLine.slice(this.#state.cursorCol);
2873
+ const graphemes = [...segmenter.segment(afterCursor)];
2874
+ const firstGrapheme = graphemes[0];
2875
+ this.#setCursorCol(this.#state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
2876
+ } else if (this.#state.cursorLine < this.#state.lines.length - 1) {
2877
+ // Wrap to start of next logical line
2878
+ this.#state.cursorLine++;
2879
+ this.#setCursorCol(0);
2880
+ } else {
2881
+ // At end of last line - can't move, but set preferredVisualCol for up/down navigation
2882
+ const currentVL = visualLines[currentVisualLine];
2883
+ if (currentVL) {
2884
+ const segmentText = currentLine.slice(currentVL.startCol, currentVL.startCol + currentVL.length);
2885
+ this.#preferredVisualCol = visualColAtOffset(segmentText, this.#state.cursorCol - currentVL.startCol);
2886
+ }
2887
+ }
2888
+ } else {
2889
+ // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
2890
+ if (this.#state.cursorCol > 0) {
2891
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2892
+ const graphemes = [...segmenter.segment(beforeCursor)];
2893
+ const lastGrapheme = graphemes[graphemes.length - 1];
2894
+ this.#setCursorCol(this.#state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
2895
+ } else if (this.#state.cursorLine > 0) {
2896
+ // Wrap to end of previous logical line
2897
+ this.#state.cursorLine--;
2898
+ const prevLine = this.#state.lines[this.#state.cursorLine] || "";
2899
+ this.#setCursorCol(prevLine.length);
2900
+ }
2901
+ }
2902
+ }
2903
+ }
2904
+
2905
+ #pageScroll(direction: -1 | 1): void {
2906
+ this.#resetKillSequence();
2907
+ const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
2908
+ const currentVisualLine = this.#findCurrentVisualLine(visualLines);
2909
+ const step = this.#getPageScrollStep(visualLines.length);
2910
+ const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * step));
2911
+ if (targetVisualLine === currentVisualLine) return;
2912
+ this.#moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
2913
+ }
2914
+
2915
+ #moveWordBackwards(): void {
2916
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2917
+
2918
+ // If at start of line, move to end of previous line
2919
+ if (this.#state.cursorCol === 0) {
2920
+ if (this.#state.cursorLine > 0) {
2921
+ this.#state.cursorLine--;
2922
+ const prevLine = this.#state.lines[this.#state.cursorLine] || "";
2923
+ this.#setCursorCol(prevLine.length);
2924
+ }
2925
+ return;
2926
+ }
2927
+
2928
+ this.#setCursorCol(moveWordLeft(currentLine, this.#state.cursorCol));
2929
+ }
2930
+
2931
+ /**
2932
+ * Jump to the first occurrence of a character in the specified direction.
2933
+ * Multi-line search. Case-sensitive. Skips the current cursor position.
2934
+ */
2935
+ #jumpToChar(char: string, direction: "forward" | "backward"): void {
2936
+ this.#resetKillSequence();
2937
+ const isForward = direction === "forward";
2938
+ const lines = this.#state.lines;
2939
+
2940
+ const end = isForward ? lines.length : -1;
2941
+ const step = isForward ? 1 : -1;
2942
+
2943
+ for (let lineIdx = this.#state.cursorLine; lineIdx !== end; lineIdx += step) {
2944
+ const line = lines[lineIdx] || "";
2945
+ const isCurrentLine = lineIdx === this.#state.cursorLine;
2946
+
2947
+ // Current line: start after/before cursor; other lines: search full line
2948
+ const searchFrom = isCurrentLine
2949
+ ? isForward
2950
+ ? this.#state.cursorCol + 1
2951
+ : this.#state.cursorCol - 1
2952
+ : undefined;
2953
+
2954
+ const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
2955
+
2956
+ if (idx !== -1) {
2957
+ this.#state.cursorLine = lineIdx;
2958
+ this.#setCursorCol(idx);
2959
+ return;
2960
+ }
2961
+ }
2962
+ // No match found - cursor stays in place
2963
+ }
2964
+
2965
+ #moveWordForwards(): void {
2966
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2967
+
2968
+ // If at end of line, move to start of next line
2969
+ if (this.#state.cursorCol >= currentLine.length) {
2970
+ if (this.#state.cursorLine < this.#state.lines.length - 1) {
2971
+ this.#state.cursorLine++;
2972
+ this.#setCursorCol(0);
2973
+ }
2974
+ return;
2975
+ }
2976
+
2977
+ this.#setCursorCol(moveWordRight(currentLine, this.#state.cursorCol));
2978
+ }
2979
+
2980
+ #hasOnlyWhitespaceBeforeCursorLine(): boolean {
2981
+ for (let i = 0; i < this.#state.cursorLine; i++) {
2982
+ if ((this.#state.lines[i] || "").trim() !== "") {
2983
+ return false;
2984
+ }
2985
+ }
2986
+ return true;
2987
+ }
2988
+
2989
+ // Slash commands execute only when the submitted prompt starts with the command.
2990
+ #isAtStartOfSubmittedMessage(): boolean {
2991
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2992
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2993
+
2994
+ return this.#hasOnlyWhitespaceBeforeCursorLine() && (beforeCursor.trim() === "" || beforeCursor.trim() === "/");
2995
+ }
2996
+
2997
+ #isInSubmittedSlashCommandContext(): boolean {
2998
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2999
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
3000
+ return this.#hasOnlyWhitespaceBeforeCursorLine() && beforeCursor.trimStart().startsWith("/");
3001
+ }
3002
+
3003
+ #isInMidPromptSkillSlashContext(): boolean {
3004
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
3005
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
3006
+ const slashStart = findTrailingSlashCommandStart(beforeCursor);
3007
+ if (slashStart === null) return false;
3008
+ if (this.#hasOnlyWhitespaceBeforeCursorLine() && findLeadingSlashCommandStart(beforeCursor) !== null)
3009
+ return false;
3010
+ return !this.#hasOnlyWhitespaceBeforeCursorLine() || beforeCursor.slice(0, slashStart).trim() !== "";
3011
+ }
3012
+
3013
+ #isInSlashAutocompleteContext(): boolean {
3014
+ return this.#isInSubmittedSlashCommandContext() || this.#isInMidPromptSkillSlashContext();
3015
+ }
3016
+
3017
+ /**
3018
+ * Decide whether the popup's `#autocompletePrefix` still safely maps onto the current
3019
+ * text before the cursor for an accept-time (`applyCompletion`) call. Mirrors the
3020
+ * re-anchoring branches in `CombinedAutocompleteProvider.applyCompletion`:
3021
+ *
3022
+ * - Exact match → always safe.
3023
+ * - Path branch is safe when the prefix is still a live suffix of the text; the
3024
+ * provider's default slice at `cursorCol - prefix.length` then hits the right span.
3025
+ * - Slash branch re-anchors when both the prefix and the current text carry a
3026
+ * leading slash command and the current slash token is clean (no whitespace or
3027
+ * inner slash), matching `applyCompletion`'s slash-branch guard. It only
3028
+ * engages for command-shaped selections: absolute-path completions (`/tmp/fo`
3029
+ * via the no-command-match fall-through) share the leading-slash prefix shape
3030
+ * but must use the live-suffix path rule so the apply slice stays anchored.
3031
+ * - Mid-prompt skill branch re-anchors when the popup item is a skill and the
3032
+ * current text still ends in a matching trailing slash token, preventing a
3033
+ * stale selection from replacing a newer skill prefix.
3034
+ * - `@`-file branch re-anchors via `#extractAtPrefix`; safe when the current text
3035
+ * still ends in a whitespace-anchored `@<token>`.
3036
+ * - Everything else is stale — accepting it would corrupt the buffer (issue #4295).
3037
+ */
3038
+ #autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string, item?: SelectItem | null): boolean {
3039
+ if (currentTextBeforeCursor === this.#autocompletePrefix) return true;
3040
+
3041
+ if (item?.value.startsWith("skill:") && findTrailingSlashCommandStart(this.#autocompletePrefix) !== null) {
3042
+ const currentTrailingStart = findTrailingSlashCommandStart(currentTextBeforeCursor);
3043
+ if (currentTrailingStart !== null) {
3044
+ const token = currentTextBeforeCursor.slice(currentTrailingStart);
3045
+ if (!token.includes(" ") && !token.slice(1).includes("/")) {
3046
+ // Guard the timing window where the popup was built for an earlier
3047
+ // query (e.g. bare `/`) and the user typed further characters before
3048
+ // the 100 ms debounced refresh fired: accept the stale skill only
3049
+ // when the refreshed popup would still surface it (same gate as
3050
+ // buildMidPromptSkillCompletions). `tmp` after a bare slash
3051
+ // therefore falls through to file completion instead of rewriting
3052
+ // the user's `/tmp` to `/skill:…`.
3053
+ const lowerToken = token.slice(1).toLowerCase();
3054
+ if (midPromptSkillTokenMatches(lowerToken, item.value, item.description)) return true;
3055
+ }
3056
+ }
3057
+ return false;
3058
+ }
3059
+
3060
+ if (findLeadingSlashCommandStart(this.#autocompletePrefix) !== null && !this.#selectedCompletionIsPath()) {
3061
+ const currentLeadingStart = findLeadingSlashCommandStart(currentTextBeforeCursor);
3062
+ if (currentLeadingStart !== null) {
3063
+ const token = currentTextBeforeCursor.slice(currentLeadingStart);
3064
+ if (!token.includes(" ") && !token.slice(1).includes("/")) return true;
3065
+ }
3066
+ return false;
3067
+ }
3068
+
3069
+ if (this.#autocompletePrefix.startsWith("@")) {
3070
+ return /(?:^|\s)@[^\s]*$/.test(currentTextBeforeCursor);
3071
+ }
3072
+
3073
+ return currentTextBeforeCursor.endsWith(this.#autocompletePrefix);
3074
+ }
3075
+
3076
+ /**
3077
+ * Whether the current popup selection inserts a file path rather than a
3078
+ * slash command. Leading-slash prefixes are ambiguous: the provider falls
3079
+ * through to absolute-path completion when no command matches, and those
3080
+ * item values start with `/` (or `"` when quoted) while command values are
3081
+ * bare names.
3082
+ */
3083
+ #selectedCompletionIsPath(): boolean {
3084
+ const selected = this.#autocompleteList?.getSelectedItem();
3085
+ if (!selected) return false;
3086
+ return selected.value.startsWith("/") || selected.value.startsWith('"');
3087
+ }
3088
+
3089
+ #isSlashCommandNameAutocompleteSelection(): boolean {
3090
+ if (this.#autocompleteState !== "regular") {
3091
+ return false;
3092
+ }
3093
+
3094
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
3095
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol).trimStart();
3096
+ return (
3097
+ this.#isInSubmittedSlashCommandContext() && textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")
3098
+ );
3099
+ }
3100
+
3101
+ #isCompletedSlashCommandAtCursor(): boolean {
3102
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
3103
+ if (this.#state.cursorCol !== currentLine.length) {
3104
+ return false;
3105
+ }
3106
+
3107
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol).trimStart();
3108
+ return this.#isInSubmittedSlashCommandContext() && /^\/\S+ $/.test(textBeforeCursor);
3109
+ }
3110
+
3111
+ // Autocomplete methods
3112
+ /**
3113
+ * Whether the text ending at the cursor looks like a `scheme://` URL token.
3114
+ * Generic by design: any scheme triggers a suggestion fetch and the active
3115
+ * provider decides whether it has candidates (returning none is a no-op).
3116
+ * MUST stay in sync with the token grammar in coding-agent's
3117
+ * `internal-url-autocomplete.ts`.
3118
+ */
3119
+ #textTriggersUrlAutocomplete(textBeforeCursor: string): boolean {
3120
+ return /(?:^|[\s"'`(<=])[a-z][a-z0-9+.-]*:\/{1,2}[^\s"'`()<>]*$/i.test(textBeforeCursor);
3121
+ }
3122
+
3123
+ async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
3124
+ if (!this.#autocompleteProvider) return;
3125
+ // Check if we should trigger file completion on Tab
3126
+ if (explicitTab) {
3127
+ const shouldTrigger =
3128
+ !this.#autocompleteProvider.shouldTriggerFileCompletion ||
3129
+ this.#autocompleteProvider.shouldTriggerFileCompletion(
3130
+ this.#state.lines,
3131
+ this.#state.cursorLine,
3132
+ this.#state.cursorCol,
3133
+ );
3134
+ if (!shouldTrigger) {
3135
+ return;
3136
+ }
3137
+ }
3138
+
3139
+ const requestId = ++this.#autocompleteRequestId;
3140
+
3141
+ const suggestions = await this.#autocompleteProvider.getSuggestions(
3142
+ this.#state.lines,
3143
+ this.#state.cursorLine,
3144
+ this.#state.cursorCol,
3145
+ );
3146
+ if (requestId !== this.#autocompleteRequestId) return;
3147
+
3148
+ if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
3149
+ this.#autocompletePrefix = suggestions.prefix;
3150
+ this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
3151
+ this.#autocompleteState = "regular";
3152
+ this.onAutocompleteUpdate?.();
3153
+ } else {
3154
+ this.#cancelAutocomplete();
3155
+ this.onAutocompleteUpdate?.();
3156
+ }
3157
+ }
3158
+ #createAutocompleteList(
3159
+ prefix: string,
3160
+ items: Array<{ value: string; label: string; description?: string }>,
3161
+ ): SelectList {
3162
+ const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : AUTOCOMPLETE_SELECT_LIST_LAYOUT;
3163
+ return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList, layout);
3164
+ }
3165
+
3166
+ async #handleTabCompletion(): Promise<void> {
3167
+ if (!this.#autocompleteProvider) return;
3168
+
3169
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
3170
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
3171
+
3172
+ if (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) {
3173
+ await this.#handleSlashCommandCompletion();
3174
+ } else if (this.#isInMidPromptSkillSlashContext()) {
3175
+ await this.#handleSlashCommandCompletion();
3176
+ if (!this.#autocompleteState) {
3177
+ await this.#forceFileAutocomplete();
3178
+ }
3179
+ } else {
3180
+ await this.#forceFileAutocomplete();
3181
+ }
3182
+ }
3183
+ async #handleSlashCommandCompletion(): Promise<void> {
3184
+ await this.#tryTriggerAutocomplete();
3185
+ }
3186
+
3187
+ async #forceFileAutocomplete(): Promise<void> {
3188
+ if (!this.#autocompleteProvider) return;
3189
+
3190
+ // File-aware providers expose getForceFileSuggestions; slash-only ones fall back to regular completion.
3191
+ const getForceFileSuggestions = this.#autocompleteProvider.getForceFileSuggestions;
3192
+ if (typeof getForceFileSuggestions !== "function") {
3193
+ await this.#tryTriggerAutocomplete(true);
3194
+ return;
3195
+ }
3196
+
3197
+ const requestId = ++this.#autocompleteRequestId;
3198
+ const suggestions = await getForceFileSuggestions.call(
3199
+ this.#autocompleteProvider,
3200
+ this.#state.lines,
3201
+ this.#state.cursorLine,
3202
+ this.#state.cursorCol,
3203
+ );
3204
+ if (requestId !== this.#autocompleteRequestId) return;
3205
+
3206
+ if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
3207
+ this.#autocompletePrefix = suggestions.prefix;
3208
+ this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
3209
+ this.#autocompleteState = "force";
3210
+ this.onAutocompleteUpdate?.();
3211
+ } else {
3212
+ this.#cancelAutocomplete();
3213
+ this.onAutocompleteUpdate?.();
3214
+ }
3215
+ }
3216
+
3217
+ #cancelAutocomplete(notifyCancel: boolean = false): void {
3218
+ const wasAutocompleting = this.#autocompleteState !== null;
3219
+ this.#clearAutocompleteTimeout();
3220
+ this.#autocompleteRequestId += 1;
3221
+ this.#autocompleteState = null;
3222
+ this.#autocompleteList = undefined;
3223
+ this.#autocompletePrefix = "";
3224
+ if (notifyCancel && wasAutocompleting) {
3225
+ this.onAutocompleteCancel?.();
3226
+ }
3227
+ }
3228
+
3229
+ isShowingAutocomplete(): boolean {
3230
+ return this.#autocompleteState !== null;
3231
+ }
3232
+
3233
+ async #updateAutocomplete(): Promise<void> {
3234
+ if (!this.#autocompleteState || !this.#autocompleteProvider) return;
3235
+
3236
+ // In force mode, use forceFileAutocomplete to get suggestions
3237
+ if (this.#autocompleteState === "force") {
3238
+ this.#forceFileAutocomplete();
3239
+ return;
3240
+ }
3241
+
3242
+ const requestId = ++this.#autocompleteRequestId;
3243
+
3244
+ const suggestions = await this.#autocompleteProvider.getSuggestions(
3245
+ this.#state.lines,
3246
+ this.#state.cursorLine,
3247
+ this.#state.cursorCol,
3248
+ );
3249
+ if (requestId !== this.#autocompleteRequestId) return;
3250
+
3251
+ if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
3252
+ this.#autocompletePrefix = suggestions.prefix;
3253
+ // Always create new SelectList to ensure update
3254
+ this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
3255
+ this.onAutocompleteUpdate?.();
3256
+ } else {
3257
+ this.#cancelAutocomplete();
3258
+ this.onAutocompleteUpdate?.();
3259
+ }
3260
+ }
3261
+
3262
+ #debouncedUpdateAutocomplete(): void {
3263
+ if (this.#autocompleteTimeout) {
3264
+ clearTimeout(this.#autocompleteTimeout);
3265
+ }
3266
+ this.#autocompleteTimeout = setTimeout(() => {
3267
+ this.#updateAutocomplete();
3268
+ this.#autocompleteTimeout = undefined;
3269
+ }, 100);
3270
+ }
3271
+
3272
+ #clearAutocompleteTimeout(): void {
3273
+ if (this.#autocompleteTimeout) {
3274
+ clearTimeout(this.#autocompleteTimeout);
3275
+ this.#autocompleteTimeout = undefined;
3276
+ }
3277
+ }
3278
+
3279
+ /**
3280
+ * Get inline hint text to show as dim ghost text after the cursor.
3281
+ * Checks selected autocomplete item's hint first, then falls back to provider.
3282
+ */
3283
+ #getInlineHint(): string | null {
3284
+ // Check selected autocomplete item for a hint
3285
+ if (this.#autocompleteState && this.#autocompleteList) {
3286
+ const selected = this.#autocompleteList.getSelectedItem();
3287
+ return selected?.hint ?? null;
3288
+ }
3289
+
3290
+ // Fall back to provider's getInlineHint
3291
+ if (this.#autocompleteProvider?.getInlineHint) {
3292
+ return this.#autocompleteProvider.getInlineHint(
3293
+ this.#state.lines,
3294
+ this.#state.cursorLine,
3295
+ this.#state.cursorCol,
3296
+ );
3297
+ }
3298
+
3299
+ return null;
3300
+ }
3301
+ }