ππ Agent

editor.ts

78 KB2352 lines
editor.ts
1import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocomplete.ts";
2import { getKeybindings } from "../keybindings.ts";
3import { decodePrintableKey, matchesKey } from "../keys.ts";
4import { KillRing } from "../kill-ring.ts";
5import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts";
6import { UndoStack } from "../undo-stack.ts";
7import {
8 cjkBreakRegex,
9 getGraphemeSegmenter,
10 getWordSegmenter,
11 isWhitespaceChar,
12 sliceByColumn,
13 visibleWidth,
14} from "../utils.ts";
15import { findWordBackward, findWordForward } from "../word-navigation.ts";
16import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts";
17
18const graphemeSegmenter = getGraphemeSegmenter();
19const wordSegmenter = getWordSegmenter();
20
21/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
22const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
23
24/** Non-global version for single-segment testing. */
25const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
26
27/** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
28function isPasteMarker(segment: string): boolean {
29 return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
30}
31
32/**
33 * A segmenter that wraps Intl.Segmenter and merges graphemes that fall
34 * within paste markers into single atomic segments. This makes cursor
35 * movement, deletion, word-wrap, etc. treat paste markers as single units.
36 *
37 * Only markers whose numeric ID exists in `validIds` are merged.
38 */
39function segmentWithMarkers(
40 text: string,
41 baseSegmenter: Intl.Segmenter,
42 validIds: Set<number>,
43): Iterable<Intl.SegmentData> {
44 // Fast path: no paste markers in the text or no valid IDs.
45 if (validIds.size === 0 || !text.includes("[paste #")) {
46 return baseSegmenter.segment(text);
47 }
48
49 // Find all marker spans with valid IDs.
50 const markers: Array<{ start: number; end: number }> = [];
51 for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
52 const id = Number.parseInt(m[1]!, 10);
53 if (!validIds.has(id)) continue;
54 markers.push({ start: m.index, end: m.index + m[0].length });
55 }
56 if (markers.length === 0) {
57 return baseSegmenter.segment(text);
58 }
59
60 // Build merged segment list.
61 const baseSegments = baseSegmenter.segment(text);
62 const result: Intl.SegmentData[] = [];
63 let markerIdx = 0;
64
65 for (const seg of baseSegments) {
66 // Skip past markers that are entirely before this segment.
67 while (markerIdx < markers.length && markers[markerIdx]!.end <= seg.index) {
68 markerIdx++;
69 }
70
71 const marker = markerIdx < markers.length ? markers[markerIdx]! : null;
72
73 if (marker && seg.index >= marker.start && seg.index < marker.end) {
74 // This segment falls inside a marker.
75 // If this is the first segment of the marker, emit a merged segment.
76 if (seg.index === marker.start) {
77 const markerText = text.slice(marker.start, marker.end);
78 result.push({
79 segment: markerText,
80 index: marker.start,
81 input: text,
82 });
83 }
84 // Otherwise skip (already merged into the first segment).
85 } else {
86 result.push(seg);
87 }
88 }
89
90 return result;
91}
92
93/**
94 * Represents a chunk of text for word-wrap layout.
95 * Tracks both the text content and its position in the original line.
96 */
97export interface TextChunk {
98 text: string;
99 startIndex: number;
100 endIndex: number;
101}
102
103/**
104 * Split a line into word-wrapped chunks.
105 * Wraps at word boundaries when possible, falling back to character-level
106 * wrapping for words longer than the available width.
107 *
108 * @param line - The text line to wrap
109 * @param maxWidth - Maximum visible width per chunk
110 * @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
111 * When omitted the default Intl.Segmenter is used.
112 * @returns Array of chunks with text and position information
113 */
114export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[] {
115 if (!line || maxWidth <= 0) {
116 return [{ text: "", startIndex: 0, endIndex: 0 }];
117 }
118
119 const lineWidth = visibleWidth(line);
120 if (lineWidth <= maxWidth) {
121 return [{ text: line, startIndex: 0, endIndex: line.length }];
122 }
123
124 const chunks: TextChunk[] = [];
125 const segments = preSegmented ?? [...graphemeSegmenter.segment(line)];
126
127 let currentWidth = 0;
128 let chunkStart = 0;
129
130 // Wrap opportunity: the position after the last whitespace before a non-whitespace
131 // grapheme, i.e. where a line break is allowed.
132 let wrapOppIndex = -1;
133 let wrapOppWidth = 0;
134
135 for (let i = 0; i < segments.length; i++) {
136 const seg = segments[i]!;
137 const grapheme = seg.segment;
138 const gWidth = visibleWidth(grapheme);
139 const charIndex = seg.index;
140 const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
141
142 // Overflow check before advancing.
143 if (currentWidth + gWidth > maxWidth) {
144 if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
145 // Backtrack to last wrap opportunity (the remaining content
146 // plus the current grapheme still fits within maxWidth).
147 chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex });
148 chunkStart = wrapOppIndex;
149 currentWidth -= wrapOppWidth;
150 } else if (chunkStart < charIndex) {
151 // No viable wrap opportunity: force-break at current position.
152 // This also handles the case where backtracking to a word
153 // boundary wouldn't help because the remaining content plus
154 // the current grapheme (e.g. a wide character) still exceeds
155 // maxWidth.
156 chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });
157 chunkStart = charIndex;
158 currentWidth = 0;
159 }
160 wrapOppIndex = -1;
161 }
162
163 if (gWidth > maxWidth) {
164 // Single atomic segment wider than maxWidth (e.g. paste marker
165 // in a narrow terminal). Re-wrap it at grapheme granularity.
166
167 // The segment remains logically atomic for cursor
168 // movement / editing — the split is purely visual for word-wrap layout.
169 const subChunks = wordWrapLine(grapheme, maxWidth);
170 for (let j = 0; j < subChunks.length - 1; j++) {
171 const sc = subChunks[j]!;
172 chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
173 }
174 const last = subChunks[subChunks.length - 1]!;
175 chunkStart = charIndex + last.startIndex;
176 currentWidth = visibleWidth(last.text);
177 wrapOppIndex = -1;
178 continue;
179 }
180
181 // Advance.
182 currentWidth += gWidth;
183
184 // Record wrap opportunity: whitespace followed by non-whitespace
185 // (multiple spaces join; the break point is after the last space),
186 // or at a boundary where either side is CJK (CJK allows breaking
187 // between any adjacent characters).
188 const next = segments[i + 1];
189 if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
190 wrapOppIndex = next.index;
191 wrapOppWidth = currentWidth;
192 } else if (!isWs && next && !isWhitespaceChar(next.segment)) {
193 const isCjk = !isPasteMarker(grapheme) && cjkBreakRegex.test(grapheme);
194 const nextIsCjk = !isPasteMarker(next.segment) && cjkBreakRegex.test(next.segment);
195 if (isCjk || nextIsCjk) {
196 wrapOppIndex = next.index;
197 wrapOppWidth = currentWidth;
198 }
199 }
200 }
201
202 // Push final chunk.
203 chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
204
205 return chunks;
206}
207
208// Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints.
209interface EditorState {
210 lines: string[];
211 cursorLine: number;
212 cursorCol: number;
213}
214
215/** Undo snapshot: editor text state plus the paste registry. */
216interface EditorSnapshot {
217 state: EditorState;
218 pastes: Map<number, string>;
219 pasteCounter: number;
220}
221
222interface LayoutLine {
223 text: string;
224 hasCursor: boolean;
225 cursorPos?: number;
226}
227
228export interface EditorTheme {
229 borderColor: (str: string) => string;
230 selectList: SelectListTheme;
231}
232
233export interface EditorOptions {
234 paddingX?: number;
235 autocompleteMaxVisible?: number;
236}
237
238const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
239 minPrimaryColumnWidth: 12,
240 maxPrimaryColumnWidth: 32,
241};
242
243const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
244const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"];
245
246function escapeCharacterClass(value: string): string {
247 return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&");
248}
249
250function buildTriggerPattern(triggerCharacters: string[]): RegExp {
251 return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`);
252}
253
254function buildDebouncePattern(triggerCharacters: string[]): RegExp {
255 const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass);
256 return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`);
257}
258
259function createScrollBorder(direction: "↑" | "↓", hiddenLineCount: number, width: number): string {
260 const availableWidth = Math.max(0, width);
261 const indicator = `─── ${direction} ${hiddenLineCount} more `;
262 const remaining = availableWidth - visibleWidth(indicator);
263 if (remaining >= 0) return indicator + "─".repeat(remaining);
264
265 const ellipsis = "...".slice(0, availableWidth);
266 const indicatorWidth = availableWidth - visibleWidth(ellipsis);
267 return sliceByColumn(indicator, 0, indicatorWidth, true) + ellipsis;
268}
269
270export class Editor implements Component, Focusable {
271 private state: EditorState = {
272 lines: [""],
273 cursorLine: 0,
274 cursorCol: 0,
275 };
276
277 /** Focusable interface - set by TUI when focus changes */
278 focused: boolean = false;
279
280 protected tui: TUI;
281 private theme: EditorTheme;
282 private paddingX: number = 0;
283
284 // Store last render width for cursor navigation
285 private lastWidth: number = 80;
286
287 // Vertical scrolling support
288 private scrollOffset: number = 0;
289
290 // Border color (can be changed dynamically)
291 public borderColor: (str: string) => string;
292
293 // Autocomplete support
294 private autocompleteProvider?: AutocompleteProvider;
295 private autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
296 private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters);
297 private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters);
298 private autocompleteList?: SelectList;
299 private autocompleteState: "regular" | "force" | null = null;
300 private autocompletePrefix: string = "";
301 private autocompleteMaxVisible: number = 5;
302 private autocompleteAbort?: AbortController;
303 private autocompleteDebounceTimer?: ReturnType<typeof setTimeout>;
304 private autocompleteRequestTask: Promise<void> = Promise.resolve();
305 private autocompleteStartToken: number = 0;
306 private autocompleteRequestId: number = 0;
307
308 // Paste tracking for large pastes
309 private pastes: Map<number, string> = new Map();
310 private pasteCounter: number = 0;
311
312 // Bracketed paste mode buffering
313 private pasteBuffer: string = "";
314 private isInPaste: boolean = false;
315
316 // Prompt history for up/down navigation
317 private history: string[] = [];
318 private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
319 private historyDraft: EditorState | null = null;
320
321 // Kill ring for Emacs-style kill/yank operations
322 private killRing = new KillRing();
323 private lastAction: "kill" | "yank" | "type-word" | null = null;
324
325 // Character jump mode
326 private jumpMode: "forward" | "backward" | null = null;
327
328 // Preferred visual column for vertical cursor movement (sticky column)
329 private preferredVisualCol: number | null = null;
330
331 // When the cursor is snapped to the start of an atomic segment, e.g. a
332 // paste marker, cursorCol no longer reflects where the cursor would have
333 // landed. This field stores the pre-snap cursorCol so that the next
334 // vertical move can resolve it to a visual column on whatever VL it belongs
335 // to.
336 private snappedFromCursorCol: number | null = null;
337
338 // Undo support
339 private undoStack = new UndoStack<EditorSnapshot>();
340
341 public onSubmit?: (text: string) => void;
342 public onChange?: (text: string) => void;
343 public disableSubmit: boolean = false;
344
345 constructor(tui: TUI, theme: EditorTheme, options: EditorOptions = {}) {
346 this.tui = tui;
347 this.theme = theme;
348 this.borderColor = theme.borderColor;
349 const paddingX = options.paddingX ?? 0;
350 this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0;
351 const maxVisible = options.autocompleteMaxVisible ?? 5;
352 this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
353 }
354
355 /** Set of currently valid paste IDs, for marker-aware segmentation. */
356 private validPasteIds(): Set<number> {
357 return new Set(this.pastes.keys());
358 }
359
360 /** Segment text with paste-marker awareness, only merging markers with valid IDs. */
361 private segment(text: string, mode: "word" | "grapheme"): Iterable<Intl.SegmentData> {
362 return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.validPasteIds());
363 }
364
365 getPaddingX(): number {
366 return this.paddingX;
367 }
368
369 setPaddingX(padding: number): void {
370 const newPadding = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) : 0;
371 if (this.paddingX !== newPadding) {
372 this.paddingX = newPadding;
373 this.tui.requestRender();
374 }
375 }
376
377 getAutocompleteMaxVisible(): number {
378 return this.autocompleteMaxVisible;
379 }
380
381 setAutocompleteMaxVisible(maxVisible: number): void {
382 const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
383 if (this.autocompleteMaxVisible !== newMaxVisible) {
384 this.autocompleteMaxVisible = newMaxVisible;
385 this.tui.requestRender();
386 }
387 }
388
389 setAutocompleteProvider(provider: AutocompleteProvider): void {
390 this.cancelAutocomplete();
391 this.autocompleteProvider = provider;
392 this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []);
393 }
394
395 /**
396 * Add a prompt to history for up/down arrow navigation.
397 * Called after successful submission.
398 */
399 addToHistory(text: string): void {
400 const trimmed = text.trim();
401 if (!trimmed) return;
402 // Don't add consecutive duplicates
403 if (this.history.length > 0 && this.history[0] === trimmed) return;
404 this.history.unshift(trimmed);
405 // Limit history size
406 if (this.history.length > 100) {
407 this.history.pop();
408 }
409 }
410
411 private isEditorEmpty(): boolean {
412 return this.state.lines.length === 1 && this.state.lines[0] === "";
413 }
414
415 private isOnFirstVisualLine(): boolean {
416 const visualLines = this.buildVisualLineMap(this.lastWidth);
417 const currentVisualLine = this.findCurrentVisualLine(visualLines);
418 return currentVisualLine === 0;
419 }
420
421 private isOnLastVisualLine(): boolean {
422 const visualLines = this.buildVisualLineMap(this.lastWidth);
423 const currentVisualLine = this.findCurrentVisualLine(visualLines);
424 return currentVisualLine === visualLines.length - 1;
425 }
426
427 private navigateHistory(direction: 1 | -1): void {
428 this.lastAction = null;
429 if (this.history.length === 0) return;
430
431 const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
432 if (newIndex < -1 || newIndex >= this.history.length) return;
433
434 // Capture state when first entering history browsing mode
435 if (this.historyIndex === -1 && newIndex >= 0) {
436 this.pushUndoSnapshot();
437 this.historyDraft = structuredClone(this.state);
438 }
439
440 this.historyIndex = newIndex;
441
442 if (this.historyIndex === -1) {
443 const draft = this.historyDraft;
444 this.historyDraft = null;
445 if (draft) {
446 this.state = draft;
447 this.preferredVisualCol = null;
448 this.snappedFromCursorCol = null;
449 this.scrollOffset = 0;
450 if (this.onChange) this.onChange(this.getText());
451 } else {
452 this.setTextInternal("");
453 }
454 } else {
455 this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end");
456 }
457 }
458
459 private exitHistoryBrowsing(): void {
460 this.historyIndex = -1;
461 this.historyDraft = null;
462 }
463
464 /** Internal setText that doesn't reset history state - used by navigateHistory */
465 private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void {
466 const lines = text.split("\n");
467 this.state.lines = lines.length === 0 ? [""] : lines;
468 this.state.cursorLine = cursorPlacement === "start" ? 0 : this.state.lines.length - 1;
469 this.setCursorCol(cursorPlacement === "start" ? 0 : this.state.lines[this.state.cursorLine]?.length || 0);
470 // Reset scroll - render() will adjust to show cursor
471 this.scrollOffset = 0;
472
473 if (this.onChange) {
474 this.onChange(this.getText());
475 }
476 }
477
478 invalidate(): void {
479 // No cached state to invalidate currently
480 }
481
482 render(width: number): string[] {
483 const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
484 const paddingX = Math.min(this.paddingX, maxPadding);
485 const contentWidth = Math.max(1, width - paddingX * 2);
486
487 // Layout width: with padding the cursor can overflow into it,
488 // without padding we reserve 1 column for the cursor.
489 const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
490
491 // Store for cursor navigation (must match wrapping width)
492 this.lastWidth = layoutWidth;
493
494 const horizontal = this.borderColor("─");
495
496 // Layout the text
497 const layoutLines = this.layoutText(layoutWidth);
498
499 // Calculate max visible lines: 30% of terminal height, minimum 5 lines
500 const terminalRows = this.tui.terminal.rows;
501 const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));
502
503 // Find the cursor line index in layoutLines
504 let cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);
505 if (cursorLineIndex === -1) cursorLineIndex = 0;
506
507 // Adjust scroll offset to keep cursor visible
508 if (cursorLineIndex < this.scrollOffset) {
509 this.scrollOffset = cursorLineIndex;
510 } else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {
511 this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;
512 }
513
514 // Clamp scroll offset to valid range
515 const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);
516 this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));
517
518 // Get visible lines slice
519 const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
520
521 const result: string[] = [];
522 const leftPadding = " ".repeat(paddingX);
523 const rightPadding = leftPadding;
524
525 // Render top border (with scroll indicator if scrolled down)
526 if (this.scrollOffset > 0) {
527 const border = createScrollBorder("↑", this.scrollOffset, width);
528 result.push(this.borderColor(border));
529 } else {
530 result.push(horizontal.repeat(width));
531 }
532
533 // Render each visible layout line
534 // Emit hardware cursor marker when focused so TUI can position the
535 // hardware cursor for IME candidate-window placement even while
536 // autocomplete (e.g. slash-command menu) is visible.
537 const emitCursorMarker = this.focused;
538
539 for (const layoutLine of visibleLines) {
540 let displayText = layoutLine.text;
541 let lineVisibleWidth = visibleWidth(layoutLine.text);
542 let cursorInPadding = false;
543
544 // Add cursor if this line has it
545 if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
546 const before = displayText.slice(0, layoutLine.cursorPos);
547 const after = displayText.slice(layoutLine.cursorPos);
548
549 // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
550 const marker = emitCursorMarker ? CURSOR_MARKER : "";
551
552 if (after.length > 0) {
553 // Cursor is on a character (grapheme) - replace it with highlighted version
554 // Get the first grapheme from 'after'
555 const afterGraphemes = [...this.segment(after, "grapheme")];
556 const firstGrapheme = afterGraphemes[0]?.segment || "";
557 const restAfter = after.slice(firstGrapheme.length);
558 const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
559 displayText = before + marker + cursor + restAfter;
560 // lineVisibleWidth stays the same - we're replacing, not adding
561 } else {
562 // Cursor is at the end - add highlighted space
563 const cursor = "\x1b[7m \x1b[0m";
564 displayText = before + marker + cursor;
565 lineVisibleWidth = lineVisibleWidth + 1;
566 // If cursor overflows content width into the padding, flag it
567 if (lineVisibleWidth > contentWidth && paddingX > 0) {
568 cursorInPadding = true;
569 }
570 }
571 }
572
573 // Calculate padding based on actual visible width
574 const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth));
575 const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding;
576
577 // Render the line (no side borders, just horizontal lines above and below)
578 result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`);
579 }
580
581 // Render bottom border (with scroll indicator if more content below)
582 const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
583 if (linesBelow > 0) {
584 const border = createScrollBorder("↓", linesBelow, width);
585 result.push(this.borderColor(border));
586 } else {
587 result.push(horizontal.repeat(width));
588 }
589
590 // Add autocomplete list if active
591 if (this.autocompleteState && this.autocompleteList) {
592 const autocompleteResult = this.autocompleteList.render(contentWidth);
593 for (const line of autocompleteResult) {
594 const lineWidth = visibleWidth(line);
595 const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth));
596 result.push(`${leftPadding}${line}${linePadding}${rightPadding}`);
597 }
598 }
599
600 return result;
601 }
602
603 handleInput(data: string): void {
604 const kb = getKeybindings();
605
606 // Handle character jump mode (awaiting next character to jump to)
607 if (this.jumpMode !== null) {
608 // Cancel if the hotkey is pressed again
609 if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
610 this.jumpMode = null;
611 return;
612 }
613
614 const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
615 if (printable !== undefined) {
616 // Printable character - perform the jump
617 const direction = this.jumpMode;
618 this.jumpMode = null;
619 this.jumpToChar(printable, direction);
620 return;
621 }
622
623 // Control character - cancel and fall through to normal handling
624 this.jumpMode = null;
625 }
626
627 // Handle bracketed paste mode
628 if (data.includes("\x1b[200~")) {
629 this.isInPaste = true;
630 this.pasteBuffer = "";
631 data = data.replace("\x1b[200~", "");
632 }
633
634 if (this.isInPaste) {
635 this.pasteBuffer += data;
636 const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
637 if (endIndex !== -1) {
638 const pasteContent = this.pasteBuffer.substring(0, endIndex);
639 if (pasteContent.length > 0) {
640 this.handlePaste(pasteContent);
641 }
642 this.isInPaste = false;
643 const remaining = this.pasteBuffer.substring(endIndex + 6);
644 this.pasteBuffer = "";
645 if (remaining.length > 0) {
646 this.handleInput(remaining);
647 }
648 return;
649 }
650 return;
651 }
652
653 // Ctrl+C - let parent handle (exit/clear)
654 if (kb.matches(data, "tui.input.copy")) {
655 return;
656 }
657
658 // Undo
659 if (kb.matches(data, "tui.editor.undo")) {
660 this.undo();
661 return;
662 }
663
664 // Handle autocomplete mode
665 if (this.autocompleteState && this.autocompleteList) {
666 if (kb.matches(data, "tui.select.cancel")) {
667 this.cancelAutocomplete();
668 return;
669 }
670
671 if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
672 this.autocompleteList.handleInput(data);
673 return;
674 }
675
676 if (kb.matches(data, "tui.input.tab")) {
677 const selected = this.autocompleteList.getSelectedItem();
678 if (selected && this.autocompleteProvider) {
679 this.pushUndoSnapshot();
680 this.lastAction = null;
681 const result = this.autocompleteProvider.applyCompletion(
682 this.state.lines,
683 this.state.cursorLine,
684 this.state.cursorCol,
685 selected,
686 this.autocompletePrefix,
687 );
688 this.state.lines = result.lines;
689 this.state.cursorLine = result.cursorLine;
690 this.setCursorCol(result.cursorCol);
691 this.cancelAutocomplete();
692 if (this.onChange) this.onChange(this.getText());
693 }
694 return;
695 }
696
697 if (kb.matches(data, "tui.select.confirm")) {
698 const selected = this.autocompleteList.getSelectedItem();
699 if (selected && this.autocompleteProvider) {
700 this.pushUndoSnapshot();
701 this.lastAction = null;
702 const result = this.autocompleteProvider.applyCompletion(
703 this.state.lines,
704 this.state.cursorLine,
705 this.state.cursorCol,
706 selected,
707 this.autocompletePrefix,
708 );
709 this.state.lines = result.lines;
710 this.state.cursorLine = result.cursorLine;
711 this.setCursorCol(result.cursorCol);
712
713 if (this.autocompletePrefix.startsWith("/")) {
714 this.cancelAutocomplete();
715 // Fall through to submit
716 } else {
717 this.cancelAutocomplete();
718 if (this.onChange) this.onChange(this.getText());
719 return;
720 }
721 }
722 }
723 }
724
725 // Tab - trigger completion
726 if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {
727 this.handleTabCompletion();
728 return;
729 }
730
731 // Deletion actions
732 if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
733 this.deleteToEndOfLine();
734 return;
735 }
736 if (kb.matches(data, "tui.editor.deleteToLineStart")) {
737 this.deleteToStartOfLine();
738 return;
739 }
740 if (kb.matches(data, "tui.editor.deleteWordBackward")) {
741 this.deleteWordBackwards();
742 return;
743 }
744 if (kb.matches(data, "tui.editor.deleteWordForward")) {
745 this.deleteWordForward();
746 return;
747 }
748 if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
749 this.handleBackspace();
750 return;
751 }
752 if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
753 this.handleForwardDelete();
754 return;
755 }
756
757 // Kill ring actions
758 if (kb.matches(data, "tui.editor.yank")) {
759 this.yank();
760 return;
761 }
762 if (kb.matches(data, "tui.editor.yankPop")) {
763 this.yankPop();
764 return;
765 }
766
767 // Cursor movement actions
768 if (kb.matches(data, "tui.editor.cursorLineStart")) {
769 this.moveToLineStart();
770 return;
771 }
772 if (kb.matches(data, "tui.editor.cursorLineEnd")) {
773 this.moveToLineEnd();
774 return;
775 }
776 if (kb.matches(data, "tui.editor.cursorWordLeft")) {
777 this.moveWordBackwards();
778 return;
779 }
780 if (kb.matches(data, "tui.editor.cursorWordRight")) {
781 this.moveWordForwards();
782 return;
783 }
784
785 // New line
786 if (
787 kb.matches(data, "tui.input.newLine") ||
788 (data.charCodeAt(0) === 10 && data.length > 1) ||
789 data === "\x1b\r" ||
790 data === "\x1b[13;2~" ||
791 (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
792 (data === "\n" && data.length === 1)
793 ) {
794 if (this.shouldSubmitOnBackslashEnter(data, kb)) {
795 this.handleBackspace();
796 this.submitValue();
797 return;
798 }
799 this.addNewLine();
800 return;
801 }
802
803 // Submit (Enter)
804 if (kb.matches(data, "tui.input.submit")) {
805 if (this.disableSubmit) return;
806
807 // Workaround for terminals without Shift+Enter support:
808 // If char before cursor is \, delete it and insert newline instead of submitting.
809 const currentLine = this.state.lines[this.state.cursorLine] || "";
810 if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
811 this.handleBackspace();
812 this.addNewLine();
813 return;
814 }
815
816 this.submitValue();
817 return;
818 }
819
820 // Arrow key navigation (with history support)
821 if (kb.matches(data, "tui.editor.cursorUp")) {
822 if (
823 this.isOnFirstVisualLine() &&
824 (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0)
825 ) {
826 this.navigateHistory(-1);
827 } else if (this.isOnFirstVisualLine()) {
828 // Already at top - jump to start of line
829 this.moveToLineStart();
830 } else {
831 this.moveCursor(-1, 0);
832 }
833 return;
834 }
835 if (kb.matches(data, "tui.editor.cursorDown")) {
836 if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
837 this.navigateHistory(1);
838 } else if (this.isOnLastVisualLine()) {
839 // Already at bottom - jump to end of line
840 this.moveToLineEnd();
841 } else {
842 this.moveCursor(1, 0);
843 }
844 return;
845 }
846 if (kb.matches(data, "tui.editor.cursorRight")) {
847 this.moveCursor(0, 1);
848 return;
849 }
850 if (kb.matches(data, "tui.editor.cursorLeft")) {
851 this.moveCursor(0, -1);
852 return;
853 }
854
855 // Page up/down - scroll by page and move cursor
856 if (kb.matches(data, "tui.editor.pageUp")) {
857 this.pageScroll(-1);
858 return;
859 }
860 if (kb.matches(data, "tui.editor.pageDown")) {
861 this.pageScroll(1);
862 return;
863 }
864
865 // Character jump mode triggers
866 if (kb.matches(data, "tui.editor.jumpForward")) {
867 this.jumpMode = "forward";
868 return;
869 }
870 if (kb.matches(data, "tui.editor.jumpBackward")) {
871 this.jumpMode = "backward";
872 return;
873 }
874
875 // Shift+Space - insert regular space
876 if (matchesKey(data, "shift+space")) {
877 this.insertCharacter(" ");
878 return;
879 }
880
881 const printable = decodePrintableKey(data);
882 if (printable !== undefined) {
883 this.insertCharacter(printable);
884 return;
885 }
886
887 // Regular characters
888 if (data.charCodeAt(0) >= 32) {
889 this.insertCharacter(data);
890 }
891 }
892
893 private layoutText(contentWidth: number): LayoutLine[] {
894 const layoutLines: LayoutLine[] = [];
895
896 if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
897 // Empty editor
898 layoutLines.push({
899 text: "",
900 hasCursor: true,
901 cursorPos: 0,
902 });
903 return layoutLines;
904 }
905
906 // Process each logical line
907 for (let i = 0; i < this.state.lines.length; i++) {
908 const line = this.state.lines[i] || "";
909 const isCurrentLine = i === this.state.cursorLine;
910 const lineVisibleWidth = visibleWidth(line);
911
912 if (lineVisibleWidth <= contentWidth) {
913 // Line fits in one layout line
914 if (isCurrentLine) {
915 layoutLines.push({
916 text: line,
917 hasCursor: true,
918 cursorPos: this.state.cursorCol,
919 });
920 } else {
921 layoutLines.push({
922 text: line,
923 hasCursor: false,
924 });
925 }
926 } else {
927 // Line needs wrapping - use word-aware wrapping
928 const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
929
930 for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
931 const chunk = chunks[chunkIndex];
932 if (!chunk) continue;
933
934 const cursorPos = this.state.cursorCol;
935 const isLastChunk = chunkIndex === chunks.length - 1;
936
937 // Determine if cursor is in this chunk
938 // For word-wrapped chunks, we need to handle the case where
939 // cursor might be in trimmed whitespace at end of chunk
940 let hasCursorInChunk = false;
941 let adjustedCursorPos = 0;
942
943 if (isCurrentLine) {
944 if (isLastChunk) {
945 // Last chunk: cursor belongs here if >= startIndex
946 hasCursorInChunk = cursorPos >= chunk.startIndex;
947 adjustedCursorPos = cursorPos - chunk.startIndex;
948 } else {
949 // Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
950 // But we need to handle the visual position in the trimmed text
951 hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
952 if (hasCursorInChunk) {
953 adjustedCursorPos = cursorPos - chunk.startIndex;
954 // Clamp to text length (in case cursor was in trimmed whitespace)
955 if (adjustedCursorPos > chunk.text.length) {
956 adjustedCursorPos = chunk.text.length;
957 }
958 }
959 }
960 }
961
962 if (hasCursorInChunk) {
963 layoutLines.push({
964 text: chunk.text,
965 hasCursor: true,
966 cursorPos: adjustedCursorPos,
967 });
968 } else {
969 layoutLines.push({
970 text: chunk.text,
971 hasCursor: false,
972 });
973 }
974 }
975 }
976 }
977
978 return layoutLines;
979 }
980
981 getText(): string {
982 return this.state.lines.join("\n");
983 }
984
985 private expandPasteMarkers(text: string): string {
986 let result = text;
987 for (const [pasteId, pasteContent] of this.pastes) {
988 const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
989 result = result.replace(markerRegex, () => pasteContent);
990 }
991 return result;
992 }
993
994 /**
995 * Get text with paste markers expanded to their actual content.
996 * Use this when you need the full content (e.g., for external editor).
997 */
998 getExpandedText(): string {
999 return this.expandPasteMarkers(this.state.lines.join("\n"));
1000 }
1001
1002 getLines(): string[] {
1003 return [...this.state.lines];
1004 }
1005
1006 getCursor(): { line: number; col: number } {
1007 return { line: this.state.cursorLine, col: this.state.cursorCol };
1008 }
1009
1010 setText(text: string): void {
1011 this.cancelAutocomplete();
1012 this.lastAction = null;
1013 this.exitHistoryBrowsing();
1014 const normalized = this.normalizeText(text);
1015 // Push undo snapshot if content differs (makes programmatic changes undoable)
1016 if (this.getText() !== normalized) {
1017 this.pushUndoSnapshot();
1018 }
1019 this.pastes.clear();
1020 this.pasteCounter = 0;
1021 this.setTextInternal(normalized);
1022 }
1023
1024 /**
1025 * Insert text at the current cursor position.
1026 * Used for programmatic insertion (e.g., clipboard image markers).
1027 * This is atomic for undo - single undo restores entire pre-insert state.
1028 */
1029 insertTextAtCursor(text: string): void {
1030 if (!text) return;
1031 this.cancelAutocomplete();
1032 this.pushUndoSnapshot();
1033 this.lastAction = null;
1034 this.exitHistoryBrowsing();
1035 this.insertTextAtCursorInternal(text);
1036 }
1037
1038 /**
1039 * Normalize text for editor storage:
1040 * - Normalize line endings (\r\n and \r -> \n)
1041 * - Expand tabs to 4 spaces
1042 */
1043 private normalizeText(text: string): string {
1044 return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
1045 }
1046
1047 /**
1048 * Internal text insertion at cursor. Handles single and multi-line text.
1049 * Does not push undo snapshots or trigger autocomplete - caller is responsible.
1050 * Normalizes line endings and calls onChange once at the end.
1051 */
1052 private insertTextAtCursorInternal(text: string): void {
1053 if (!text) return;
1054
1055 // Normalize line endings and tabs
1056 const normalized = this.normalizeText(text);
1057 const insertedLines = normalized.split("\n");
1058
1059 const currentLine = this.state.lines[this.state.cursorLine] || "";
1060 const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1061 const afterCursor = currentLine.slice(this.state.cursorCol);
1062
1063 if (insertedLines.length === 1) {
1064 // Single line - insert at cursor position
1065 this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor;
1066 this.setCursorCol(this.state.cursorCol + normalized.length);
1067 } else {
1068 // Multi-line insertion
1069 this.state.lines = [
1070 // All lines before current line
1071 ...this.state.lines.slice(0, this.state.cursorLine),
1072
1073 // The first inserted line merged with text before cursor
1074 beforeCursor + insertedLines[0],
1075
1076 // All middle inserted lines
1077 ...insertedLines.slice(1, -1),
1078
1079 // The last inserted line with text after cursor
1080 insertedLines[insertedLines.length - 1] + afterCursor,
1081
1082 // All lines after current line
1083 ...this.state.lines.slice(this.state.cursorLine + 1),
1084 ];
1085
1086 this.state.cursorLine += insertedLines.length - 1;
1087 this.setCursorCol((insertedLines[insertedLines.length - 1] || "").length);
1088 }
1089
1090 if (this.onChange) {
1091 this.onChange(this.getText());
1092 }
1093 }
1094
1095 // All the editor methods from before...
1096 private insertCharacter(char: string, skipUndoCoalescing?: boolean): void {
1097 this.exitHistoryBrowsing();
1098
1099 // Undo coalescing (fish-style):
1100 // - Consecutive word chars coalesce into one undo unit
1101 // - Space captures state before itself (so undo removes space+following word together)
1102 // - Each space is separately undoable
1103 // Skip coalescing when called from atomic operations (e.g., handlePaste)
1104 if (!skipUndoCoalescing) {
1105 if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
1106 this.pushUndoSnapshot();
1107 }
1108 this.lastAction = "type-word";
1109 }
1110
1111 const line = this.state.lines[this.state.cursorLine] || "";
1112
1113 const before = line.slice(0, this.state.cursorCol);
1114 const after = line.slice(this.state.cursorCol);
1115
1116 this.state.lines[this.state.cursorLine] = before + char + after;
1117 this.setCursorCol(this.state.cursorCol + char.length);
1118
1119 if (this.onChange) {
1120 this.onChange(this.getText());
1121 }
1122
1123 // Check if we should trigger or update autocomplete
1124 if (!this.autocompleteState) {
1125 // Auto-trigger for "/" at the start of a line (slash commands)
1126 if (char === "/" && this.isAtStartOfMessage()) {
1127 this.tryTriggerAutocomplete();
1128 }
1129 // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries
1130 else if (this.autocompleteTriggerCharacters.includes(char)) {
1131 const currentLine = this.state.lines[this.state.cursorLine] || "";
1132 const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1133 const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
1134 if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
1135 this.tryTriggerAutocomplete();
1136 }
1137 }
1138 // Also auto-trigger when typing letters in a slash command or symbol completion context
1139 else if (/[a-zA-Z0-9.\-_]/.test(char)) {
1140 const currentLine = this.state.lines[this.state.cursorLine] || "";
1141 const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1142 // Check if we're in a slash command (with or without space for arguments)
1143 if (this.isInSlashCommandContext(textBeforeCursor)) {
1144 this.tryTriggerAutocomplete();
1145 }
1146 // Check if we're in a symbol-based completion context like @, #, or provider triggers
1147 else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
1148 this.tryTriggerAutocomplete();
1149 }
1150 }
1151 } else {
1152 this.updateAutocomplete();
1153 }
1154 }
1155
1156 private handlePaste(pastedText: string): void {
1157 this.cancelAutocomplete();
1158 this.exitHistoryBrowsing();
1159 this.lastAction = null;
1160
1161 this.pushUndoSnapshot();
1162
1163 // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1164 // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1165 // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1166 // per-char filter below preserves newlines instead of stripping ESC and
1167 // leaking the printable tail (e.g. "[106;5u") into the editor.
1168 const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1169 const cp = Number(code);
1170 if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1171 if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1172 return match;
1173 });
1174
1175 // Clean the pasted text: normalize line endings, expand tabs
1176 const cleanText = this.normalizeText(decodedText);
1177
1178 // Filter out non-printable characters except newlines
1179 let filteredText = cleanText
1180 .split("")
1181 .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
1182 .join("");
1183
1184 // If pasting a file path (starts with /, ~, or .) and the character before
1185 // the cursor is a word character, prepend a space for better readability
1186 if (/^[/~.]/.test(filteredText)) {
1187 const currentLine = this.state.lines[this.state.cursorLine] || "";
1188 const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : "";
1189 if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
1190 filteredText = ` ${filteredText}`;
1191 }
1192 }
1193
1194 // Split into lines to check for large paste
1195 const pastedLines = filteredText.split("\n");
1196
1197 // Check if this is a large paste (> 10 lines or > 1000 characters)
1198 const totalChars = filteredText.length;
1199 if (pastedLines.length > 10 || totalChars > 1000) {
1200 // Store the paste and insert a marker
1201 this.pasteCounter++;
1202 const pasteId = this.pasteCounter;
1203 this.pastes.set(pasteId, filteredText);
1204
1205 // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
1206 const marker =
1207 pastedLines.length > 10
1208 ? `[paste #${pasteId} +${pastedLines.length} lines]`
1209 : `[paste #${pasteId} ${totalChars} chars]`;
1210 this.insertTextAtCursorInternal(marker);
1211 return;
1212 }
1213
1214 if (pastedLines.length === 1) {
1215 // Single line - insert atomically (do not trigger autocomplete during paste)
1216 this.insertTextAtCursorInternal(filteredText);
1217 return;
1218 }
1219
1220 // Multi-line paste - use direct state manipulation
1221 this.insertTextAtCursorInternal(filteredText);
1222 }
1223
1224 private addNewLine(): void {
1225 this.cancelAutocomplete();
1226 this.exitHistoryBrowsing();
1227 this.lastAction = null;
1228
1229 this.pushUndoSnapshot();
1230
1231 const currentLine = this.state.lines[this.state.cursorLine] || "";
1232
1233 const before = currentLine.slice(0, this.state.cursorCol);
1234 const after = currentLine.slice(this.state.cursorCol);
1235
1236 // Split current line
1237 this.state.lines[this.state.cursorLine] = before;
1238 this.state.lines.splice(this.state.cursorLine + 1, 0, after);
1239
1240 // Move cursor to start of new line
1241 this.state.cursorLine++;
1242 this.setCursorCol(0);
1243
1244 if (this.onChange) {
1245 this.onChange(this.getText());
1246 }
1247 }
1248
1249 private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType<typeof getKeybindings>): boolean {
1250 if (this.disableSubmit) return false;
1251 if (!matchesKey(data, "enter")) return false;
1252 const submitKeys = kb.getKeys("tui.input.submit");
1253 const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
1254 if (!hasShiftEnter) return false;
1255
1256 const currentLine = this.state.lines[this.state.cursorLine] || "";
1257 return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
1258 }
1259
1260 private submitValue(): void {
1261 this.cancelAutocomplete();
1262 const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
1263
1264 this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
1265 this.pastes.clear();
1266 this.pasteCounter = 0;
1267 this.exitHistoryBrowsing();
1268 this.scrollOffset = 0;
1269 this.undoStack.clear();
1270 this.lastAction = null;
1271
1272 if (this.onChange) this.onChange("");
1273 if (this.onSubmit) this.onSubmit(result);
1274 }
1275
1276 private handleBackspace(): void {
1277 this.exitHistoryBrowsing();
1278 this.lastAction = null;
1279
1280 if (this.state.cursorCol > 0) {
1281 this.pushUndoSnapshot();
1282
1283 // Delete grapheme before cursor (handles emojis, combining characters, etc.)
1284 let line = this.state.lines[this.state.cursorLine] || "";
1285 const beforeCursor = line.slice(0, this.state.cursorCol);
1286
1287 // Find the last grapheme in the text before cursor
1288 const graphemes = [...this.segment(beforeCursor, "grapheme")];
1289 const lastGrapheme = graphemes[graphemes.length - 1];
1290 const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1291 const isPastedSegmented = PASTE_MARKER_SINGLE.exec(lastGrapheme.segment);
1292
1293 if (isPastedSegmented) {
1294 // This contains the id part e.g 4 from [paste #4 +123 lines]
1295 const targetId = Number(isPastedSegmented[1]);
1296 this.pastes.delete(targetId);
1297 this.pasteCounter--;
1298
1299 // Shift registry entries down in ascending id order, independent
1300 // of marker order in the text ([paste #3] becomes [paste #2] when
1301 // [paste #1] is removed).
1302 const higherIds = [...this.pastes.keys()].filter((id) => id > targetId).sort((a, b) => a - b);
1303 for (const id of higherIds) {
1304 this.pastes.set(id - 1, this.pastes.get(id)!);
1305 this.pastes.delete(id);
1306 }
1307
1308 // Renumber markers with ids greater than the removed one.
1309 this.state.lines = this.state.lines.map((line) =>
1310 line.replace(PASTE_MARKER_REGEX, (fullMatch, idGroup, suffixGroup) => {
1311 const x = Number(idGroup);
1312 if (x <= targetId) return fullMatch;
1313 return `[paste #${x - 1}${suffixGroup}]`;
1314 }),
1315 );
1316 }
1317
1318 line = this.state.lines[this.state.cursorLine] || "";
1319
1320 const before = line.slice(0, this.state.cursorCol - graphemeLength);
1321 const after = line.slice(this.state.cursorCol);
1322
1323 this.state.lines[this.state.cursorLine] = before + after;
1324 this.setCursorCol(this.state.cursorCol - graphemeLength);
1325 } else if (this.state.cursorLine > 0) {
1326 this.pushUndoSnapshot();
1327
1328 // Merge with previous line
1329 const currentLine = this.state.lines[this.state.cursorLine] || "";
1330 const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
1331
1332 this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1333 this.state.lines.splice(this.state.cursorLine, 1);
1334
1335 this.state.cursorLine--;
1336 this.setCursorCol(previousLine.length);
1337 }
1338
1339 if (this.onChange) {
1340 this.onChange(this.getText());
1341 }
1342
1343 // Update or re-trigger autocomplete after backspace
1344 if (this.autocompleteState) {
1345 this.updateAutocomplete();
1346 } else {
1347 // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
1348 const currentLine = this.state.lines[this.state.cursorLine] || "";
1349 const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1350 // Slash command context
1351 if (this.isInSlashCommandContext(textBeforeCursor)) {
1352 this.tryTriggerAutocomplete();
1353 }
1354 // Symbol-based completion context like @, #, or provider triggers
1355 else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
1356 this.tryTriggerAutocomplete();
1357 }
1358 }
1359 }
1360
1361 /**
1362 * Set cursor column and clear preferredVisualCol.
1363 * Use this for all non-vertical cursor movements to reset sticky column behavior.
1364 */
1365 private setCursorCol(col: number): void {
1366 this.state.cursorCol = col;
1367 this.preferredVisualCol = null;
1368 this.snappedFromCursorCol = null;
1369 }
1370
1371 /**
1372 * Move cursor to a target visual line, applying sticky column logic.
1373 * Shared by moveCursor() and pageScroll().
1374 */
1375 private moveToVisualLine(
1376 visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
1377 currentVisualLine: number,
1378 targetVisualLine: number,
1379 ): void {
1380 const currentVL = visualLines[currentVisualLine];
1381 const targetVL = visualLines[targetVisualLine];
1382 if (!(currentVL && targetVL)) return;
1383
1384 // When the cursor was snapped to a segment start, resolve the pre-snap
1385 // position against the VL it belongs to. This gives the correct visual
1386 // column even after a resize reshuffles VLs.
1387 let currentVisualCol: number;
1388 if (this.snappedFromCursorCol !== null) {
1389 const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
1390 currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
1391 } else {
1392 currentVisualCol = this.state.cursorCol - currentVL.startCol;
1393 }
1394
1395 // For non-last segments, clamp to length-1 to stay within the segment
1396 const isLastSourceSegment =
1397 currentVisualLine === visualLines.length - 1 ||
1398 visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1399 const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
1400
1401 const isLastTargetSegment =
1402 targetVisualLine === visualLines.length - 1 ||
1403 visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1404 const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
1405
1406 const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1407
1408 // Set cursor position
1409 this.state.cursorLine = targetVL.logicalLine;
1410 const targetCol = targetVL.startCol + moveToVisualCol;
1411 const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1412 this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1413
1414 // Snap cursor to atomic segment boundary (e.g. paste markers)
1415 // so the cursor never lands in the middle of a multi-grapheme unit.
1416 // Single-grapheme segments don't need snapping.
1417 const segments = [...this.segment(logicalLine, "grapheme")];
1418 for (const seg of segments) {
1419 if (seg.index > this.state.cursorCol) break;
1420 if (seg.segment.length <= 1) continue;
1421 if (this.state.cursorCol < seg.index + seg.segment.length) {
1422 const isContinuation = seg.index < targetVL.startCol;
1423 const isMovingDown = targetVisualLine > currentVisualLine;
1424
1425 if (isContinuation && isMovingDown) {
1426 // The segment started on a previous visual line, and we
1427 // already visited it on the way down. Skip all remaining
1428 // continuation VLs and land on the first VL past it.
1429 const segEnd = seg.index + seg.segment.length;
1430 let next = targetVisualLine + 1;
1431 while (
1432 next < visualLines.length &&
1433 visualLines[next].logicalLine === targetVL.logicalLine &&
1434 visualLines[next].startCol < segEnd
1435 ) {
1436 next++;
1437 }
1438 if (next < visualLines.length) {
1439 this.moveToVisualLine(visualLines, currentVisualLine, next);
1440 return;
1441 }
1442 }
1443
1444 // Snap to the start of the segment so it gets highlighted.
1445 // Store the pre-snap position so the next vertical move can
1446 // resolve it to the correct visual column.
1447 this.snappedFromCursorCol = this.state.cursorCol;
1448 this.state.cursorCol = seg.index;
1449 return;
1450 }
1451 }
1452
1453 // No snap occurred – we moved out of the atomic segment.
1454 this.snappedFromCursorCol = null;
1455 }
1456
1457 /**
1458 * Compute the target visual column for vertical cursor movement.
1459 * Implements the sticky column decision table:
1460 *
1461 * | P | S | T | U | Scenario | Set Preferred | Move To |
1462 * |---|---|---|---| ---------------------------------------------------- |---------------|-------------|
1463 * | 0 | * | 0 | - | Start nav, target fits | null | current |
1464 * | 0 | * | 1 | - | Start nav, target shorter | current | target end |
1465 * | 1 | 0 | 0 | 0 | Clamped, target fits preferred | null | preferred |
1466 * | 1 | 0 | 0 | 1 | Clamped, target longer but still can't fit preferred | keep | target end |
1467 * | 1 | 0 | 1 | - | Clamped, target even shorter | keep | target end |
1468 * | 1 | 1 | 0 | - | Rewrapped, target fits current | null | current |
1469 * | 1 | 1 | 1 | - | Rewrapped, target shorter than current | current | target end |
1470 *
1471 * Where:
1472 * - P = preferred col is set
1473 * - S = cursor in middle of source line (not clamped to end)
1474 * - T = target line shorter than current visual col
1475 * - U = target line shorter than preferred col
1476 */
1477 private computeVerticalMoveColumn(
1478 currentVisualCol: number,
1479 sourceMaxVisualCol: number,
1480 targetMaxVisualCol: number,
1481 ): number {
1482 const hasPreferred = this.preferredVisualCol !== null; // P
1483 const cursorInMiddle = currentVisualCol < sourceMaxVisualCol; // S
1484 const targetTooShort = targetMaxVisualCol < currentVisualCol; // T
1485
1486 if (!hasPreferred || cursorInMiddle) {
1487 if (targetTooShort) {
1488 // Cases 2 and 7
1489 this.preferredVisualCol = currentVisualCol;
1490 return targetMaxVisualCol;
1491 }
1492
1493 // Cases 1 and 6
1494 this.preferredVisualCol = null;
1495 return currentVisualCol;
1496 }
1497
1498 const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol!; // U
1499 if (targetTooShort || targetCantFitPreferred) {
1500 // Cases 4 and 5
1501 return targetMaxVisualCol;
1502 }
1503
1504 // Case 3
1505 const result = this.preferredVisualCol!;
1506 this.preferredVisualCol = null;
1507 return result;
1508 }
1509
1510 private moveToLineStart(): void {
1511 this.lastAction = null;
1512 this.setCursorCol(0);
1513 }
1514
1515 private moveToLineEnd(): void {
1516 this.lastAction = null;
1517 const currentLine = this.state.lines[this.state.cursorLine] || "";
1518 this.setCursorCol(currentLine.length);
1519 }
1520
1521 private deleteToStartOfLine(): void {
1522 this.exitHistoryBrowsing();
1523
1524 const currentLine = this.state.lines[this.state.cursorLine] || "";
1525
1526 if (this.state.cursorCol > 0) {
1527 this.pushUndoSnapshot();
1528
1529 // Calculate text to be deleted and save to kill ring (backward deletion = prepend)
1530 const deletedText = currentLine.slice(0, this.state.cursorCol);
1531 this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" });
1532 this.lastAction = "kill";
1533
1534 // Delete from start of line up to cursor
1535 this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
1536 this.setCursorCol(0);
1537 } else if (this.state.cursorLine > 0) {
1538 this.pushUndoSnapshot();
1539
1540 // At start of line - merge with previous line, treating newline as deleted text
1541 this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
1542 this.lastAction = "kill";
1543
1544 const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
1545 this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1546 this.state.lines.splice(this.state.cursorLine, 1);
1547 this.state.cursorLine--;
1548 this.setCursorCol(previousLine.length);
1549 }
1550
1551 if (this.onChange) {
1552 this.onChange(this.getText());
1553 }
1554 }
1555
1556 private deleteToEndOfLine(): void {
1557 this.exitHistoryBrowsing();
1558
1559 const currentLine = this.state.lines[this.state.cursorLine] || "";
1560
1561 if (this.state.cursorCol < currentLine.length) {
1562 this.pushUndoSnapshot();
1563
1564 // Calculate text to be deleted and save to kill ring (forward deletion = append)
1565 const deletedText = currentLine.slice(this.state.cursorCol);
1566 this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" });
1567 this.lastAction = "kill";
1568
1569 // Delete from cursor to end of line
1570 this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
1571 } else if (this.state.cursorLine < this.state.lines.length - 1) {
1572 this.pushUndoSnapshot();
1573
1574 // At end of line - merge with next line, treating newline as deleted text
1575 this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
1576 this.lastAction = "kill";
1577
1578 const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
1579 this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1580 this.state.lines.splice(this.state.cursorLine + 1, 1);
1581 }
1582
1583 if (this.onChange) {
1584 this.onChange(this.getText());
1585 }
1586 }
1587
1588 private deleteWordBackwards(): void {
1589 this.exitHistoryBrowsing();
1590
1591 const currentLine = this.state.lines[this.state.cursorLine] || "";
1592
1593 // If at start of line, behave like backspace at column 0 (merge with previous line)
1594 if (this.state.cursorCol === 0) {
1595 if (this.state.cursorLine > 0) {
1596 this.pushUndoSnapshot();
1597
1598 // Treat newline as deleted text (backward deletion = prepend)
1599 this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
1600 this.lastAction = "kill";
1601
1602 const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
1603 this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1604 this.state.lines.splice(this.state.cursorLine, 1);
1605 this.state.cursorLine--;
1606 this.setCursorCol(previousLine.length);
1607 }
1608 } else {
1609 this.pushUndoSnapshot();
1610
1611 // Save lastAction before cursor movement (moveWordBackwards resets it)
1612 const wasKill = this.lastAction === "kill";
1613
1614 const oldCursorCol = this.state.cursorCol;
1615 this.moveWordBackwards();
1616 const deleteFrom = this.state.cursorCol;
1617 this.setCursorCol(oldCursorCol);
1618
1619 const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol);
1620 this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });
1621 this.lastAction = "kill";
1622
1623 this.state.lines[this.state.cursorLine] =
1624 currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
1625 this.setCursorCol(deleteFrom);
1626 }
1627
1628 if (this.onChange) {
1629 this.onChange(this.getText());
1630 }
1631 }
1632
1633 private deleteWordForward(): void {
1634 this.exitHistoryBrowsing();
1635
1636 const currentLine = this.state.lines[this.state.cursorLine] || "";
1637
1638 // If at end of line, merge with next line (delete the newline)
1639 if (this.state.cursorCol >= currentLine.length) {
1640 if (this.state.cursorLine < this.state.lines.length - 1) {
1641 this.pushUndoSnapshot();
1642
1643 // Treat newline as deleted text (forward deletion = append)
1644 this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
1645 this.lastAction = "kill";
1646
1647 const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
1648 this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1649 this.state.lines.splice(this.state.cursorLine + 1, 1);
1650 }
1651 } else {
1652 this.pushUndoSnapshot();
1653
1654 // Save lastAction before cursor movement (moveWordForwards resets it)
1655 const wasKill = this.lastAction === "kill";
1656
1657 const oldCursorCol = this.state.cursorCol;
1658 this.moveWordForwards();
1659 const deleteTo = this.state.cursorCol;
1660 this.setCursorCol(oldCursorCol);
1661
1662 const deletedText = currentLine.slice(this.state.cursorCol, deleteTo);
1663 this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });
1664 this.lastAction = "kill";
1665
1666 this.state.lines[this.state.cursorLine] =
1667 currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo);
1668 }
1669
1670 if (this.onChange) {
1671 this.onChange(this.getText());
1672 }
1673 }
1674
1675 private handleForwardDelete(): void {
1676 this.exitHistoryBrowsing();
1677 this.lastAction = null;
1678
1679 const currentLine = this.state.lines[this.state.cursorLine] || "";
1680
1681 if (this.state.cursorCol < currentLine.length) {
1682 this.pushUndoSnapshot();
1683
1684 // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
1685 const afterCursor = currentLine.slice(this.state.cursorCol);
1686
1687 // Find the first grapheme at cursor
1688 const graphemes = [...this.segment(afterCursor, "grapheme")];
1689 const firstGrapheme = graphemes[0];
1690 const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
1691
1692 const before = currentLine.slice(0, this.state.cursorCol);
1693 const after = currentLine.slice(this.state.cursorCol + graphemeLength);
1694 this.state.lines[this.state.cursorLine] = before + after;
1695 } else if (this.state.cursorLine < this.state.lines.length - 1) {
1696 this.pushUndoSnapshot();
1697
1698 // At end of line - merge with next line
1699 const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
1700 this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1701 this.state.lines.splice(this.state.cursorLine + 1, 1);
1702 }
1703
1704 if (this.onChange) {
1705 this.onChange(this.getText());
1706 }
1707
1708 // Update or re-trigger autocomplete after forward delete
1709 if (this.autocompleteState) {
1710 this.updateAutocomplete();
1711 } else {
1712 const currentLine = this.state.lines[this.state.cursorLine] || "";
1713 const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1714 // Slash command context
1715 if (this.isInSlashCommandContext(textBeforeCursor)) {
1716 this.tryTriggerAutocomplete();
1717 }
1718 // Symbol-based completion context like @, #, or provider triggers
1719 else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
1720 this.tryTriggerAutocomplete();
1721 }
1722 }
1723 }
1724
1725 /**
1726 * Build a mapping from visual lines to logical positions.
1727 * Returns an array where each element represents a visual line with:
1728 * - logicalLine: index into this.state.lines
1729 * - startCol: starting column in the logical line
1730 * - length: length of this visual line segment
1731 */
1732 private buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> {
1733 const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = [];
1734
1735 for (let i = 0; i < this.state.lines.length; i++) {
1736 const line = this.state.lines[i] || "";
1737 const lineVisWidth = visibleWidth(line);
1738 if (line.length === 0) {
1739 // Empty line still takes one visual line
1740 visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
1741 } else if (lineVisWidth <= width) {
1742 visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
1743 } else {
1744 // Line needs wrapping - use word-aware wrapping
1745 const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]);
1746 for (const chunk of chunks) {
1747 visualLines.push({
1748 logicalLine: i,
1749 startCol: chunk.startIndex,
1750 length: chunk.endIndex - chunk.startIndex,
1751 });
1752 }
1753 }
1754 }
1755
1756 return visualLines;
1757 }
1758
1759 /**
1760 * Find the visual line index that contains the given logical position.
1761 */
1762 private findVisualLineAt(
1763 visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
1764 line: number,
1765 col: number,
1766 ): number {
1767 for (let i = 0; i < visualLines.length; i++) {
1768 const vl = visualLines[i];
1769 if (!vl || vl.logicalLine !== line) continue;
1770 const offset = col - vl.startCol;
1771 // Cursor is in this segment if it's within range. For the last
1772 // segment of a logical line, cursor can be at length (end position)
1773 const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1774 if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
1775 return i;
1776 }
1777 }
1778 return visualLines.length - 1;
1779 }
1780
1781 /**
1782 * Find the visual line index for the current cursor position.
1783 */
1784 private findCurrentVisualLine(
1785 visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
1786 ): number {
1787 return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
1788 }
1789
1790 private moveCursor(deltaLine: number, deltaCol: number): void {
1791 this.lastAction = null;
1792 const visualLines = this.buildVisualLineMap(this.lastWidth);
1793 const currentVisualLine = this.findCurrentVisualLine(visualLines);
1794
1795 if (deltaLine !== 0) {
1796 const targetVisualLine = currentVisualLine + deltaLine;
1797
1798 if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
1799 this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
1800 }
1801 }
1802
1803 if (deltaCol !== 0) {
1804 const currentLine = this.state.lines[this.state.cursorLine] || "";
1805
1806 if (deltaCol > 0) {
1807 // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1808 if (this.state.cursorCol < currentLine.length) {
1809 const afterCursor = currentLine.slice(this.state.cursorCol);
1810 const graphemes = [...this.segment(afterCursor, "grapheme")];
1811 const firstGrapheme = graphemes[0];
1812 this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1813 } else if (this.state.cursorLine < this.state.lines.length - 1) {
1814 // Wrap to start of next logical line
1815 this.state.cursorLine++;
1816 this.setCursorCol(0);
1817 } else {
1818 // At end of last line - can't move, but set preferredVisualCol for up/down navigation
1819 const currentVL = visualLines[currentVisualLine];
1820 if (currentVL) {
1821 this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
1822 }
1823 }
1824 } else {
1825 // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1826 if (this.state.cursorCol > 0) {
1827 const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1828 const graphemes = [...this.segment(beforeCursor, "grapheme")];
1829 const lastGrapheme = graphemes[graphemes.length - 1];
1830 this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1831 } else if (this.state.cursorLine > 0) {
1832 // Wrap to end of previous logical line
1833 this.state.cursorLine--;
1834 const prevLine = this.state.lines[this.state.cursorLine] || "";
1835 this.setCursorCol(prevLine.length);
1836 }
1837 }
1838 }
1839
1840 // Keep an open autocomplete picker in sync with the new cursor
1841 // position: cursor movement changes the text before the cursor, so a
1842 // picker computed for the old position is stale. Re-query so it
1843 // refreshes — or closes when the new position yields no suggestions —
1844 // mirroring insertCharacter()/handleBackspace(). Without this, arrowing
1845 // left from `/cmd ` back into the command name leaves the argument
1846 // picker showing against a `/cmd` prefix (and a Tab there would
1847 // concatenate the stale suggestion onto the partial command name).
1848 if (this.autocompleteState) {
1849 this.updateAutocomplete();
1850 }
1851 }
1852
1853 /**
1854 * Scroll by a page (direction: -1 for up, 1 for down).
1855 * Moves cursor by the page size while keeping it in bounds.
1856 */
1857 private pageScroll(direction: -1 | 1): void {
1858 this.lastAction = null;
1859 const terminalRows = this.tui.terminal.rows;
1860 const pageSize = Math.max(5, Math.floor(terminalRows * 0.3));
1861
1862 const visualLines = this.buildVisualLineMap(this.lastWidth);
1863 const currentVisualLine = this.findCurrentVisualLine(visualLines);
1864 const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize));
1865
1866 this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
1867 }
1868
1869 private moveWordBackwards(): void {
1870 this.lastAction = null;
1871 const currentLine = this.state.lines[this.state.cursorLine] || "";
1872
1873 // If at start of line, move to end of previous line
1874 if (this.state.cursorCol === 0) {
1875 if (this.state.cursorLine > 0) {
1876 this.state.cursorLine--;
1877 const prevLine = this.state.lines[this.state.cursorLine] || "";
1878 this.setCursorCol(prevLine.length);
1879 }
1880 return;
1881 }
1882
1883 this.setCursorCol(
1884 findWordBackward(currentLine, this.state.cursorCol, {
1885 segment: (text) => this.segment(text, "word"),
1886 isAtomicSegment: isPasteMarker,
1887 }),
1888 );
1889 }
1890
1891 /**
1892 * Yank (paste) the most recent kill ring entry at cursor position.
1893 */
1894 private yank(): void {
1895 if (this.killRing.length === 0) return;
1896
1897 this.pushUndoSnapshot();
1898
1899 const text = this.killRing.peek()!;
1900 this.insertYankedText(text);
1901
1902 this.lastAction = "yank";
1903 }
1904
1905 /**
1906 * Cycle through kill ring (only works immediately after yank or yank-pop).
1907 * Replaces the last yanked text with the previous entry in the ring.
1908 */
1909 private yankPop(): void {
1910 // Only works if we just yanked and have more than one entry
1911 if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
1912
1913 this.pushUndoSnapshot();
1914
1915 // Delete the previously yanked text (still at end of ring before rotation)
1916 this.deleteYankedText();
1917
1918 // Rotate the ring: move end to front
1919 this.killRing.rotate();
1920
1921 // Insert the new most recent entry (now at end after rotation)
1922 const text = this.killRing.peek()!;
1923 this.insertYankedText(text);
1924
1925 this.lastAction = "yank";
1926 }
1927
1928 /**
1929 * Insert text at cursor position (used by yank operations).
1930 */
1931 private insertYankedText(text: string): void {
1932 this.exitHistoryBrowsing();
1933 const lines = text.split("\n");
1934
1935 if (lines.length === 1) {
1936 // Single line - insert at cursor
1937 const currentLine = this.state.lines[this.state.cursorLine] || "";
1938 const before = currentLine.slice(0, this.state.cursorCol);
1939 const after = currentLine.slice(this.state.cursorCol);
1940 this.state.lines[this.state.cursorLine] = before + text + after;
1941 this.setCursorCol(this.state.cursorCol + text.length);
1942 } else {
1943 // Multi-line insert
1944 const currentLine = this.state.lines[this.state.cursorLine] || "";
1945 const before = currentLine.slice(0, this.state.cursorCol);
1946 const after = currentLine.slice(this.state.cursorCol);
1947
1948 // First line merges with text before cursor
1949 this.state.lines[this.state.cursorLine] = before + (lines[0] || "");
1950
1951 // Insert middle lines
1952 for (let i = 1; i < lines.length - 1; i++) {
1953 this.state.lines.splice(this.state.cursorLine + i, 0, lines[i] || "");
1954 }
1955
1956 // Last line merges with text after cursor
1957 const lastLineIndex = this.state.cursorLine + lines.length - 1;
1958 this.state.lines.splice(lastLineIndex, 0, (lines[lines.length - 1] || "") + after);
1959
1960 // Update cursor position
1961 this.state.cursorLine = lastLineIndex;
1962 this.setCursorCol((lines[lines.length - 1] || "").length);
1963 }
1964
1965 if (this.onChange) {
1966 this.onChange(this.getText());
1967 }
1968 }
1969
1970 /**
1971 * Delete the previously yanked text (used by yank-pop).
1972 * The yanked text is derived from killRing[end] since it hasn't been rotated yet.
1973 */
1974 private deleteYankedText(): void {
1975 const yankedText = this.killRing.peek();
1976 if (!yankedText) return;
1977
1978 const yankLines = yankedText.split("\n");
1979
1980 if (yankLines.length === 1) {
1981 // Single line - delete backward from cursor
1982 const currentLine = this.state.lines[this.state.cursorLine] || "";
1983 const deleteLen = yankedText.length;
1984 const before = currentLine.slice(0, this.state.cursorCol - deleteLen);
1985 const after = currentLine.slice(this.state.cursorCol);
1986 this.state.lines[this.state.cursorLine] = before + after;
1987 this.setCursorCol(this.state.cursorCol - deleteLen);
1988 } else {
1989 // Multi-line delete - cursor is at end of last yanked line
1990 const startLine = this.state.cursorLine - (yankLines.length - 1);
1991 const startCol = (this.state.lines[startLine] || "").length - (yankLines[0] || "").length;
1992
1993 // Get text after cursor on current line
1994 const afterCursor = (this.state.lines[this.state.cursorLine] || "").slice(this.state.cursorCol);
1995
1996 // Get text before yank start position
1997 const beforeYank = (this.state.lines[startLine] || "").slice(0, startCol);
1998
1999 // Remove all lines from startLine to cursorLine and replace with merged line
2000 this.state.lines.splice(startLine, yankLines.length, beforeYank + afterCursor);
2001
2002 // Update cursor
2003 this.state.cursorLine = startLine;
2004 this.setCursorCol(startCol);
2005 }
2006
2007 if (this.onChange) {
2008 this.onChange(this.getText());
2009 }
2010 }
2011
2012 private pushUndoSnapshot(): void {
2013 this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter });
2014 }
2015
2016 private undo(): void {
2017 this.exitHistoryBrowsing();
2018 const snapshot = this.undoStack.pop();
2019 if (!snapshot) return;
2020 Object.assign(this.state, snapshot.state);
2021 this.pastes = snapshot.pastes;
2022 this.pasteCounter = snapshot.pasteCounter;
2023 this.lastAction = null;
2024 this.preferredVisualCol = null;
2025 if (this.onChange) {
2026 this.onChange(this.getText());
2027 }
2028 }
2029
2030 /**
2031 * Jump to the first occurrence of a character in the specified direction.
2032 * Multi-line search. Case-sensitive. Skips the current cursor position.
2033 */
2034 private jumpToChar(char: string, direction: "forward" | "backward"): void {
2035 this.lastAction = null;
2036 const isForward = direction === "forward";
2037 const lines = this.state.lines;
2038
2039 const end = isForward ? lines.length : -1;
2040 const step = isForward ? 1 : -1;
2041
2042 for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) {
2043 const line = lines[lineIdx] || "";
2044 const isCurrentLine = lineIdx === this.state.cursorLine;
2045
2046 // Current line: start after/before cursor; other lines: search full line
2047 const searchFrom = isCurrentLine
2048 ? isForward
2049 ? this.state.cursorCol + 1
2050 : this.state.cursorCol - 1
2051 : undefined;
2052
2053 const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
2054
2055 if (idx !== -1) {
2056 this.state.cursorLine = lineIdx;
2057 this.setCursorCol(idx);
2058 return;
2059 }
2060 }
2061 // No match found - cursor stays in place
2062 }
2063
2064 private moveWordForwards(): void {
2065 this.lastAction = null;
2066 const currentLine = this.state.lines[this.state.cursorLine] || "";
2067
2068 // If at end of line, move to start of next line
2069 if (this.state.cursorCol >= currentLine.length) {
2070 if (this.state.cursorLine < this.state.lines.length - 1) {
2071 this.state.cursorLine++;
2072 this.setCursorCol(0);
2073 }
2074 return;
2075 }
2076
2077 this.setCursorCol(
2078 findWordForward(currentLine, this.state.cursorCol, {
2079 segment: (text) => this.segment(text, "word"),
2080 isAtomicSegment: isPasteMarker,
2081 }),
2082 );
2083 }
2084
2085 // Slash menu only allowed on the first line of the editor
2086 private isSlashMenuAllowed(): boolean {
2087 return this.state.cursorLine === 0;
2088 }
2089
2090 // Helper method to check if cursor is at start of message (for slash command detection)
2091 private isAtStartOfMessage(): boolean {
2092 if (!this.isSlashMenuAllowed()) return false;
2093 const currentLine = this.state.lines[this.state.cursorLine] || "";
2094 const beforeCursor = currentLine.slice(0, this.state.cursorCol);
2095 return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
2096 }
2097
2098 private isInSlashCommandContext(textBeforeCursor: string): boolean {
2099 return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
2100 }
2101
2102 // Autocomplete methods
2103 /**
2104 * Find the best autocomplete item index for the given prefix.
2105 * Returns -1 if no match is found.
2106 *
2107 * Match priority:
2108 * 1. Exact match (prefix === item.value) -> always selected
2109 * 2. Prefix match -> first item whose value starts with prefix
2110 * 3. No match -> -1 (keep default highlight)
2111 *
2112 * Matching is case-sensitive and checks item.value only.
2113 */
2114 private getBestAutocompleteMatchIndex(items: Array<{ value: string; label: string }>, prefix: string): number {
2115 if (!prefix) return -1;
2116
2117 let firstPrefixIndex = -1;
2118
2119 for (let i = 0; i < items.length; i++) {
2120 const value = items[i]!.value;
2121 if (value === prefix) {
2122 return i; // Exact match always wins
2123 }
2124 if (firstPrefixIndex === -1 && value.startsWith(prefix)) {
2125 firstPrefixIndex = i;
2126 }
2127 }
2128
2129 return firstPrefixIndex;
2130 }
2131
2132 private createAutocompleteList(
2133 prefix: string,
2134 items: Array<{ value: string; label: string; description?: string }>,
2135 ): SelectList {
2136 const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
2137 return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
2138 }
2139
2140 private tryTriggerAutocomplete(explicitTab: boolean = false): void {
2141 this.requestAutocomplete({ force: false, explicitTab });
2142 }
2143
2144 private handleTabCompletion(): void {
2145 if (!this.autocompleteProvider) return;
2146
2147 const currentLine = this.state.lines[this.state.cursorLine] || "";
2148 const beforeCursor = currentLine.slice(0, this.state.cursorCol);
2149
2150 if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
2151 this.handleSlashCommandCompletion();
2152 } else {
2153 this.forceFileAutocomplete(true);
2154 }
2155 }
2156
2157 private handleSlashCommandCompletion(): void {
2158 this.requestAutocomplete({ force: false, explicitTab: true });
2159 }
2160
2161 private forceFileAutocomplete(explicitTab: boolean = false): void {
2162 this.requestAutocomplete({ force: true, explicitTab });
2163 }
2164
2165 private requestAutocomplete(options: { force: boolean; explicitTab: boolean }): void {
2166 if (!this.autocompleteProvider) return;
2167
2168 if (options.force) {
2169 const shouldTrigger =
2170 !this.autocompleteProvider.shouldTriggerFileCompletion ||
2171 this.autocompleteProvider.shouldTriggerFileCompletion(
2172 this.state.lines,
2173 this.state.cursorLine,
2174 this.state.cursorCol,
2175 );
2176 if (!shouldTrigger) {
2177 return;
2178 }
2179 }
2180
2181 this.cancelAutocompleteRequest();
2182 const startToken = ++this.autocompleteStartToken;
2183
2184 const debounceMs = this.getAutocompleteDebounceMs(options);
2185 if (debounceMs > 0) {
2186 this.autocompleteDebounceTimer = setTimeout(() => {
2187 this.autocompleteDebounceTimer = undefined;
2188 void this.startAutocompleteRequest(startToken, options);
2189 }, debounceMs);
2190 return;
2191 }
2192
2193 void this.startAutocompleteRequest(startToken, options);
2194 }
2195
2196 private async startAutocompleteRequest(
2197 startToken: number,
2198 options: { force: boolean; explicitTab: boolean },
2199 ): Promise<void> {
2200 const previousTask = this.autocompleteRequestTask;
2201 this.autocompleteRequestTask = (async () => {
2202 await previousTask;
2203 if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) {
2204 return;
2205 }
2206
2207 const controller = new AbortController();
2208 this.autocompleteAbort = controller;
2209 const requestId = ++this.autocompleteRequestId;
2210 const snapshotText = this.getText();
2211 const snapshotLine = this.state.cursorLine;
2212 const snapshotCol = this.state.cursorCol;
2213
2214 await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options);
2215 })();
2216 await this.autocompleteRequestTask;
2217 }
2218
2219 private setAutocompleteTriggerCharacters(triggerCharacters: string[]): void {
2220 const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
2221 for (const character of triggerCharacters) {
2222 if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) {
2223 continue;
2224 }
2225 next.push(character);
2226 }
2227 this.autocompleteTriggerCharacters = next;
2228 this.autocompleteTriggerPattern = buildTriggerPattern(next);
2229 this.autocompleteDebouncePattern = buildDebouncePattern(next);
2230 }
2231
2232 private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number {
2233 if (options.explicitTab || options.force) {
2234 return 0;
2235 }
2236
2237 const currentLine = this.state.lines[this.state.cursorLine] || "";
2238 const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
2239 return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
2240 }
2241
2242 private async runAutocompleteRequest(
2243 requestId: number,
2244 controller: AbortController,
2245 snapshotText: string,
2246 snapshotLine: number,
2247 snapshotCol: number,
2248 options: { force: boolean; explicitTab: boolean },
2249 ): Promise<void> {
2250 if (!this.autocompleteProvider) return;
2251
2252 const suggestions = await this.autocompleteProvider.getSuggestions(
2253 this.state.lines,
2254 this.state.cursorLine,
2255 this.state.cursorCol,
2256 { signal: controller.signal, force: options.force },
2257 );
2258
2259 if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) {
2260 return;
2261 }
2262
2263 this.autocompleteAbort = undefined;
2264
2265 if (!suggestions || !Array.isArray(suggestions.items) || suggestions.items.length === 0) {
2266 this.cancelAutocomplete();
2267 this.tui.requestRender();
2268 return;
2269 }
2270
2271 if (options.force && options.explicitTab && suggestions.items.length === 1) {
2272 const item = suggestions.items[0]!;
2273 this.pushUndoSnapshot();
2274 this.lastAction = null;
2275 const result = this.autocompleteProvider.applyCompletion(
2276 this.state.lines,
2277 this.state.cursorLine,
2278 this.state.cursorCol,
2279 item,
2280 suggestions.prefix,
2281 );
2282 this.state.lines = result.lines;
2283 this.state.cursorLine = result.cursorLine;
2284 this.setCursorCol(result.cursorCol);
2285 if (this.onChange) this.onChange(this.getText());
2286 this.tui.requestRender();
2287 return;
2288 }
2289
2290 this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular");
2291 this.tui.requestRender();
2292 }
2293
2294 private isAutocompleteRequestCurrent(
2295 requestId: number,
2296 controller: AbortController,
2297 snapshotText: string,
2298 snapshotLine: number,
2299 snapshotCol: number,
2300 ): boolean {
2301 return (
2302 !controller.signal.aborted &&
2303 requestId === this.autocompleteRequestId &&
2304 this.getText() === snapshotText &&
2305 this.state.cursorLine === snapshotLine &&
2306 this.state.cursorCol === snapshotCol
2307 );
2308 }
2309
2310 private applyAutocompleteSuggestions(suggestions: AutocompleteSuggestions, state: "regular" | "force"): void {
2311 this.autocompletePrefix = suggestions.prefix;
2312 this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
2313
2314 const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
2315 if (bestMatchIndex >= 0) {
2316 this.autocompleteList.setSelectedIndex(bestMatchIndex);
2317 }
2318
2319 this.autocompleteState = state;
2320 }
2321
2322 private cancelAutocompleteRequest(): void {
2323 this.autocompleteStartToken += 1;
2324 if (this.autocompleteDebounceTimer) {
2325 clearTimeout(this.autocompleteDebounceTimer);
2326 this.autocompleteDebounceTimer = undefined;
2327 }
2328 this.autocompleteAbort?.abort();
2329 this.autocompleteAbort = undefined;
2330 }
2331
2332 private clearAutocompleteUi(): void {
2333 this.autocompleteState = null;
2334 this.autocompleteList = undefined;
2335 this.autocompletePrefix = "";
2336 }
2337
2338 private cancelAutocomplete(): void {
2339 this.cancelAutocompleteRequest();
2340 this.clearAutocompleteUi();
2341 }
2342
2343 public isShowingAutocomplete(): boolean {
2344 return this.autocompleteState !== null;
2345 }
2346
2347 private updateAutocomplete(): void {
2348 if (!this.autocompleteState || !this.autocompleteProvider) return;
2349 this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false });
2350 }
2351}
2352