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";1718const graphemeSegmenter = getGraphemeSegmenter();19const wordSegmenter = getWordSegmenter();2021/** 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;2324/** Non-global version for single-segment testing. */25const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;2627/** 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}3132/**33 * A segmenter that wraps Intl.Segmenter and merges graphemes that fall34 * within paste markers into single atomic segments. This makes cursor35 * 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 }4849 // 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 }5960 // Build merged segment list.61 const baseSegments = baseSegmenter.segment(text);62 const result: Intl.SegmentData[] = [];63 let markerIdx = 0;6465 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 }7071 const marker = markerIdx < markers.length ? markers[markerIdx]! : null;7273 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 }8990 return result;91}9293/**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}102103/**104 * Split a line into word-wrapped chunks.105 * Wraps at word boundaries when possible, falling back to character-level106 * wrapping for words longer than the available width.107 *108 * @param line - The text line to wrap109 * @param maxWidth - Maximum visible width per chunk110 * @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 information113 */114export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[] {115 if (!line || maxWidth <= 0) {116 return [{ text: "", startIndex: 0, endIndex: 0 }];117 }118119 const lineWidth = visibleWidth(line);120 if (lineWidth <= maxWidth) {121 return [{ text: line, startIndex: 0, endIndex: line.length }];122 }123124 const chunks: TextChunk[] = [];125 const segments = preSegmented ?? [...graphemeSegmenter.segment(line)];126127 let currentWidth = 0;128 let chunkStart = 0;129130 // Wrap opportunity: the position after the last whitespace before a non-whitespace131 // grapheme, i.e. where a line break is allowed.132 let wrapOppIndex = -1;133 let wrapOppWidth = 0;134135 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);141142 // 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 content146 // 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 word153 // boundary wouldn't help because the remaining content plus154 // the current grapheme (e.g. a wide character) still exceeds155 // maxWidth.156 chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });157 chunkStart = charIndex;158 currentWidth = 0;159 }160 wrapOppIndex = -1;161 }162163 if (gWidth > maxWidth) {164 // Single atomic segment wider than maxWidth (e.g. paste marker165 // in a narrow terminal). Re-wrap it at grapheme granularity.166167 // The segment remains logically atomic for cursor168 // 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 }180181 // Advance.182 currentWidth += gWidth;183184 // Record wrap opportunity: whitespace followed by non-whitespace185 // (multiple spaces join; the break point is after the last space),186 // or at a boundary where either side is CJK (CJK allows breaking187 // 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 }201202 // Push final chunk.203 chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });204205 return chunks;206}207208// Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints.209interface EditorState {210 lines: string[];211 cursorLine: number;212 cursorCol: number;213}214215/** Undo snapshot: editor text state plus the paste registry. */216interface EditorSnapshot {217 state: EditorState;218 pastes: Map<number, string>;219 pasteCounter: number;220}221222interface LayoutLine {223 text: string;224 hasCursor: boolean;225 cursorPos?: number;226}227228export interface EditorTheme {229 borderColor: (str: string) => string;230 selectList: SelectListTheme;231}232233export interface EditorOptions {234 paddingX?: number;235 autocompleteMaxVisible?: number;236}237238const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {239 minPrimaryColumnWidth: 12,240 maxPrimaryColumnWidth: 32,241};242243const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;244const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"];245246function escapeCharacterClass(value: string): string {247 return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&");248}249250function buildTriggerPattern(triggerCharacters: string[]): RegExp {251 return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`);252}253254function buildDebouncePattern(triggerCharacters: string[]): RegExp {255 const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass);256 return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`);257}258259function 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);264265 const ellipsis = "...".slice(0, availableWidth);266 const indicatorWidth = availableWidth - visibleWidth(ellipsis);267 return sliceByColumn(indicator, 0, indicatorWidth, true) + ellipsis;268}269270export class Editor implements Component, Focusable {271 private state: EditorState = {272 lines: [""],273 cursorLine: 0,274 cursorCol: 0,275 };276277 /** Focusable interface - set by TUI when focus changes */278 focused: boolean = false;279280 protected tui: TUI;281 private theme: EditorTheme;282 private paddingX: number = 0;283284 // Store last render width for cursor navigation285 private lastWidth: number = 80;286287 // Vertical scrolling support288 private scrollOffset: number = 0;289290 // Border color (can be changed dynamically)291 public borderColor: (str: string) => string;292293 // Autocomplete support294 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;307308 // Paste tracking for large pastes309 private pastes: Map<number, string> = new Map();310 private pasteCounter: number = 0;311312 // Bracketed paste mode buffering313 private pasteBuffer: string = "";314 private isInPaste: boolean = false;315316 // Prompt history for up/down navigation317 private history: string[] = [];318 private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.319 private historyDraft: EditorState | null = null;320321 // Kill ring for Emacs-style kill/yank operations322 private killRing = new KillRing();323 private lastAction: "kill" | "yank" | "type-word" | null = null;324325 // Character jump mode326 private jumpMode: "forward" | "backward" | null = null;327328 // Preferred visual column for vertical cursor movement (sticky column)329 private preferredVisualCol: number | null = null;330331 // When the cursor is snapped to the start of an atomic segment, e.g. a332 // paste marker, cursorCol no longer reflects where the cursor would have333 // landed. This field stores the pre-snap cursorCol so that the next334 // vertical move can resolve it to a visual column on whatever VL it belongs335 // to.336 private snappedFromCursorCol: number | null = null;337338 // Undo support339 private undoStack = new UndoStack<EditorSnapshot>();340341 public onSubmit?: (text: string) => void;342 public onChange?: (text: string) => void;343 public disableSubmit: boolean = false;344345 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 }354355 /** Set of currently valid paste IDs, for marker-aware segmentation. */356 private validPasteIds(): Set<number> {357 return new Set(this.pastes.keys());358 }359360 /** 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 }364365 getPaddingX(): number {366 return this.paddingX;367 }368369 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 }376377 getAutocompleteMaxVisible(): number {378 return this.autocompleteMaxVisible;379 }380381 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 }388389 setAutocompleteProvider(provider: AutocompleteProvider): void {390 this.cancelAutocomplete();391 this.autocompleteProvider = provider;392 this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []);393 }394395 /**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 duplicates403 if (this.history.length > 0 && this.history[0] === trimmed) return;404 this.history.unshift(trimmed);405 // Limit history size406 if (this.history.length > 100) {407 this.history.pop();408 }409 }410411 private isEditorEmpty(): boolean {412 return this.state.lines.length === 1 && this.state.lines[0] === "";413 }414415 private isOnFirstVisualLine(): boolean {416 const visualLines = this.buildVisualLineMap(this.lastWidth);417 const currentVisualLine = this.findCurrentVisualLine(visualLines);418 return currentVisualLine === 0;419 }420421 private isOnLastVisualLine(): boolean {422 const visualLines = this.buildVisualLineMap(this.lastWidth);423 const currentVisualLine = this.findCurrentVisualLine(visualLines);424 return currentVisualLine === visualLines.length - 1;425 }426427 private navigateHistory(direction: 1 | -1): void {428 this.lastAction = null;429 if (this.history.length === 0) return;430431 const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases432 if (newIndex < -1 || newIndex >= this.history.length) return;433434 // Capture state when first entering history browsing mode435 if (this.historyIndex === -1 && newIndex >= 0) {436 this.pushUndoSnapshot();437 this.historyDraft = structuredClone(this.state);438 }439440 this.historyIndex = newIndex;441442 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 }458459 private exitHistoryBrowsing(): void {460 this.historyIndex = -1;461 this.historyDraft = null;462 }463464 /** 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 cursor471 this.scrollOffset = 0;472473 if (this.onChange) {474 this.onChange(this.getText());475 }476 }477478 invalidate(): void {479 // No cached state to invalidate currently480 }481482 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);486487 // 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));490491 // Store for cursor navigation (must match wrapping width)492 this.lastWidth = layoutWidth;493494 const horizontal = this.borderColor("─");495496 // Layout the text497 const layoutLines = this.layoutText(layoutWidth);498499 // Calculate max visible lines: 30% of terminal height, minimum 5 lines500 const terminalRows = this.tui.terminal.rows;501 const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));502503 // Find the cursor line index in layoutLines504 let cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);505 if (cursorLineIndex === -1) cursorLineIndex = 0;506507 // Adjust scroll offset to keep cursor visible508 if (cursorLineIndex < this.scrollOffset) {509 this.scrollOffset = cursorLineIndex;510 } else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {511 this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;512 }513514 // Clamp scroll offset to valid range515 const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);516 this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));517518 // Get visible lines slice519 const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);520521 const result: string[] = [];522 const leftPadding = " ".repeat(paddingX);523 const rightPadding = leftPadding;524525 // 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 }532533 // Render each visible layout line534 // Emit hardware cursor marker when focused so TUI can position the535 // hardware cursor for IME candidate-window placement even while536 // autocomplete (e.g. slash-command menu) is visible.537 const emitCursorMarker = this.focused;538539 for (const layoutLine of visibleLines) {540 let displayText = layoutLine.text;541 let lineVisibleWidth = visibleWidth(layoutLine.text);542 let cursorInPadding = false;543544 // Add cursor if this line has it545 if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {546 const before = displayText.slice(0, layoutLine.cursorPos);547 const after = displayText.slice(layoutLine.cursorPos);548549 // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)550 const marker = emitCursorMarker ? CURSOR_MARKER : "";551552 if (after.length > 0) {553 // Cursor is on a character (grapheme) - replace it with highlighted version554 // 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 adding561 } else {562 // Cursor is at the end - add highlighted space563 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 it567 if (lineVisibleWidth > contentWidth && paddingX > 0) {568 cursorInPadding = true;569 }570 }571 }572573 // Calculate padding based on actual visible width574 const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth));575 const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding;576577 // Render the line (no side borders, just horizontal lines above and below)578 result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`);579 }580581 // 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 }589590 // Add autocomplete list if active591 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 }599600 return result;601 }602603 handleInput(data: string): void {604 const kb = getKeybindings();605606 // Handle character jump mode (awaiting next character to jump to)607 if (this.jumpMode !== null) {608 // Cancel if the hotkey is pressed again609 if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {610 this.jumpMode = null;611 return;612 }613614 const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);615 if (printable !== undefined) {616 // Printable character - perform the jump617 const direction = this.jumpMode;618 this.jumpMode = null;619 this.jumpToChar(printable, direction);620 return;621 }622623 // Control character - cancel and fall through to normal handling624 this.jumpMode = null;625 }626627 // Handle bracketed paste mode628 if (data.includes("\x1b[200~")) {629 this.isInPaste = true;630 this.pasteBuffer = "";631 data = data.replace("\x1b[200~", "");632 }633634 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 }652653 // Ctrl+C - let parent handle (exit/clear)654 if (kb.matches(data, "tui.input.copy")) {655 return;656 }657658 // Undo659 if (kb.matches(data, "tui.editor.undo")) {660 this.undo();661 return;662 }663664 // Handle autocomplete mode665 if (this.autocompleteState && this.autocompleteList) {666 if (kb.matches(data, "tui.select.cancel")) {667 this.cancelAutocomplete();668 return;669 }670671 if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {672 this.autocompleteList.handleInput(data);673 return;674 }675676 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 }696697 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);712713 if (this.autocompletePrefix.startsWith("/")) {714 this.cancelAutocomplete();715 // Fall through to submit716 } else {717 this.cancelAutocomplete();718 if (this.onChange) this.onChange(this.getText());719 return;720 }721 }722 }723 }724725 // Tab - trigger completion726 if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {727 this.handleTabCompletion();728 return;729 }730731 // Deletion actions732 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 }756757 // Kill ring actions758 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 }766767 // Cursor movement actions768 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 }784785 // New line786 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 }802803 // Submit (Enter)804 if (kb.matches(data, "tui.input.submit")) {805 if (this.disableSubmit) return;806807 // 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 }815816 this.submitValue();817 return;818 }819820 // 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 line829 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 line840 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 }854855 // Page up/down - scroll by page and move cursor856 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 }864865 // Character jump mode triggers866 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 }874875 // Shift+Space - insert regular space876 if (matchesKey(data, "shift+space")) {877 this.insertCharacter(" ");878 return;879 }880881 const printable = decodePrintableKey(data);882 if (printable !== undefined) {883 this.insertCharacter(printable);884 return;885 }886887 // Regular characters888 if (data.charCodeAt(0) >= 32) {889 this.insertCharacter(data);890 }891 }892893 private layoutText(contentWidth: number): LayoutLine[] {894 const layoutLines: LayoutLine[] = [];895896 if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {897 // Empty editor898 layoutLines.push({899 text: "",900 hasCursor: true,901 cursorPos: 0,902 });903 return layoutLines;904 }905906 // Process each logical line907 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);911912 if (lineVisibleWidth <= contentWidth) {913 // Line fits in one layout line914 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 wrapping928 const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);929930 for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {931 const chunk = chunks[chunkIndex];932 if (!chunk) continue;933934 const cursorPos = this.state.cursorCol;935 const isLastChunk = chunkIndex === chunks.length - 1;936937 // Determine if cursor is in this chunk938 // For word-wrapped chunks, we need to handle the case where939 // cursor might be in trimmed whitespace at end of chunk940 let hasCursorInChunk = false;941 let adjustedCursorPos = 0;942943 if (isCurrentLine) {944 if (isLastChunk) {945 // Last chunk: cursor belongs here if >= startIndex946 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 text951 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 }961962 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 }977978 return layoutLines;979 }980981 getText(): string {982 return this.state.lines.join("\n");983 }984985 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 }993994 /**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 }10011002 getLines(): string[] {1003 return [...this.state.lines];1004 }10051006 getCursor(): { line: number; col: number } {1007 return { line: this.state.cursorLine, col: this.state.cursorCol };1008 }10091010 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 }10231024 /**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 }10371038 /**1039 * Normalize text for editor storage:1040 * - Normalize line endings (\r\n and \r -> \n)1041 * - Expand tabs to 4 spaces1042 */1043 private normalizeText(text: string): string {1044 return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");1045 }10461047 /**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;10541055 // Normalize line endings and tabs1056 const normalized = this.normalizeText(text);1057 const insertedLines = normalized.split("\n");10581059 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);10621063 if (insertedLines.length === 1) {1064 // Single line - insert at cursor position1065 this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor;1066 this.setCursorCol(this.state.cursorCol + normalized.length);1067 } else {1068 // Multi-line insertion1069 this.state.lines = [1070 // All lines before current line1071 ...this.state.lines.slice(0, this.state.cursorLine),10721073 // The first inserted line merged with text before cursor1074 beforeCursor + insertedLines[0],10751076 // All middle inserted lines1077 ...insertedLines.slice(1, -1),10781079 // The last inserted line with text after cursor1080 insertedLines[insertedLines.length - 1] + afterCursor,10811082 // All lines after current line1083 ...this.state.lines.slice(this.state.cursorLine + 1),1084 ];10851086 this.state.cursorLine += insertedLines.length - 1;1087 this.setCursorCol((insertedLines[insertedLines.length - 1] || "").length);1088 }10891090 if (this.onChange) {1091 this.onChange(this.getText());1092 }1093 }10941095 // All the editor methods from before...1096 private insertCharacter(char: string, skipUndoCoalescing?: boolean): void {1097 this.exitHistoryBrowsing();10981099 // Undo coalescing (fish-style):1100 // - Consecutive word chars coalesce into one undo unit1101 // - Space captures state before itself (so undo removes space+following word together)1102 // - Each space is separately undoable1103 // 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 }11101111 const line = this.state.lines[this.state.cursorLine] || "";11121113 const before = line.slice(0, this.state.cursorCol);1114 const after = line.slice(this.state.cursorCol);11151116 this.state.lines[this.state.cursorLine] = before + char + after;1117 this.setCursorCol(this.state.cursorCol + char.length);11181119 if (this.onChange) {1120 this.onChange(this.getText());1121 }11221123 // Check if we should trigger or update autocomplete1124 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 boundaries1130 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 context1139 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 triggers1147 else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {1148 this.tryTriggerAutocomplete();1149 }1150 }1151 } else {1152 this.updateAutocomplete();1153 }1154 }11551156 private handlePaste(pastedText: string): void {1157 this.cancelAutocomplete();1158 this.exitHistoryBrowsing();1159 this.lastAction = null;11601161 this.pushUndoSnapshot();11621163 // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode1164 // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences1165 // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the1166 // per-char filter below preserves newlines instead of stripping ESC and1167 // 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 });11741175 // Clean the pasted text: normalize line endings, expand tabs1176 const cleanText = this.normalizeText(decodedText);11771178 // Filter out non-printable characters except newlines1179 let filteredText = cleanText1180 .split("")1181 .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)1182 .join("");11831184 // If pasting a file path (starts with /, ~, or .) and the character before1185 // the cursor is a word character, prepend a space for better readability1186 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 }11931194 // Split into lines to check for large paste1195 const pastedLines = filteredText.split("\n");11961197 // 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 marker1201 this.pasteCounter++;1202 const pasteId = this.pasteCounter;1203 this.pastes.set(pasteId, filteredText);12041205 // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"1206 const marker =1207 pastedLines.length > 101208 ? `[paste #${pasteId} +${pastedLines.length} lines]`1209 : `[paste #${pasteId} ${totalChars} chars]`;1210 this.insertTextAtCursorInternal(marker);1211 return;1212 }12131214 if (pastedLines.length === 1) {1215 // Single line - insert atomically (do not trigger autocomplete during paste)1216 this.insertTextAtCursorInternal(filteredText);1217 return;1218 }12191220 // Multi-line paste - use direct state manipulation1221 this.insertTextAtCursorInternal(filteredText);1222 }12231224 private addNewLine(): void {1225 this.cancelAutocomplete();1226 this.exitHistoryBrowsing();1227 this.lastAction = null;12281229 this.pushUndoSnapshot();12301231 const currentLine = this.state.lines[this.state.cursorLine] || "";12321233 const before = currentLine.slice(0, this.state.cursorCol);1234 const after = currentLine.slice(this.state.cursorCol);12351236 // Split current line1237 this.state.lines[this.state.cursorLine] = before;1238 this.state.lines.splice(this.state.cursorLine + 1, 0, after);12391240 // Move cursor to start of new line1241 this.state.cursorLine++;1242 this.setCursorCol(0);12431244 if (this.onChange) {1245 this.onChange(this.getText());1246 }1247 }12481249 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;12551256 const currentLine = this.state.lines[this.state.cursorLine] || "";1257 return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";1258 }12591260 private submitValue(): void {1261 this.cancelAutocomplete();1262 const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();12631264 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;12711272 if (this.onChange) this.onChange("");1273 if (this.onSubmit) this.onSubmit(result);1274 }12751276 private handleBackspace(): void {1277 this.exitHistoryBrowsing();1278 this.lastAction = null;12791280 if (this.state.cursorCol > 0) {1281 this.pushUndoSnapshot();12821283 // 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);12861287 // Find the last grapheme in the text before cursor1288 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);12921293 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--;12981299 // Shift registry entries down in ascending id order, independent1300 // of marker order in the text ([paste #3] becomes [paste #2] when1301 // [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 }13071308 // 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 }13171318 line = this.state.lines[this.state.cursorLine] || "";13191320 const before = line.slice(0, this.state.cursorCol - graphemeLength);1321 const after = line.slice(this.state.cursorCol);13221323 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();13271328 // Merge with previous line1329 const currentLine = this.state.lines[this.state.cursorLine] || "";1330 const previousLine = this.state.lines[this.state.cursorLine - 1] || "";13311332 this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;1333 this.state.lines.splice(this.state.cursorLine, 1);13341335 this.state.cursorLine--;1336 this.setCursorCol(previousLine.length);1337 }13381339 if (this.onChange) {1340 this.onChange(this.getText());1341 }13421343 // Update or re-trigger autocomplete after backspace1344 if (this.autocompleteState) {1345 this.updateAutocomplete();1346 } else {1347 // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context1348 const currentLine = this.state.lines[this.state.cursorLine] || "";1349 const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);1350 // Slash command context1351 if (this.isInSlashCommandContext(textBeforeCursor)) {1352 this.tryTriggerAutocomplete();1353 }1354 // Symbol-based completion context like @, #, or provider triggers1355 else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {1356 this.tryTriggerAutocomplete();1357 }1358 }1359 }13601361 /**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 }13701371 /**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;13831384 // When the cursor was snapped to a segment start, resolve the pre-snap1385 // position against the VL it belongs to. This gives the correct visual1386 // 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 }13941395 // For non-last segments, clamp to length-1 to stay within the segment1396 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);14001401 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);14051406 const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);14071408 // Set cursor position1409 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);14131414 // 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;14241425 if (isContinuation && isMovingDown) {1426 // The segment started on a previous visual line, and we1427 // already visited it on the way down. Skip all remaining1428 // 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 < segEnd1435 ) {1436 next++;1437 }1438 if (next < visualLines.length) {1439 this.moveToVisualLine(visualLines, currentVisualLine, next);1440 return;1441 }1442 }14431444 // Snap to the start of the segment so it gets highlighted.1445 // Store the pre-snap position so the next vertical move can1446 // resolve it to the correct visual column.1447 this.snappedFromCursorCol = this.state.cursorCol;1448 this.state.cursorCol = seg.index;1449 return;1450 }1451 }14521453 // No snap occurred – we moved out of the atomic segment.1454 this.snappedFromCursorCol = null;1455 }14561457 /**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 set1473 * - S = cursor in middle of source line (not clamped to end)1474 * - T = target line shorter than current visual col1475 * - U = target line shorter than preferred col1476 */1477 private computeVerticalMoveColumn(1478 currentVisualCol: number,1479 sourceMaxVisualCol: number,1480 targetMaxVisualCol: number,1481 ): number {1482 const hasPreferred = this.preferredVisualCol !== null; // P1483 const cursorInMiddle = currentVisualCol < sourceMaxVisualCol; // S1484 const targetTooShort = targetMaxVisualCol < currentVisualCol; // T14851486 if (!hasPreferred || cursorInMiddle) {1487 if (targetTooShort) {1488 // Cases 2 and 71489 this.preferredVisualCol = currentVisualCol;1490 return targetMaxVisualCol;1491 }14921493 // Cases 1 and 61494 this.preferredVisualCol = null;1495 return currentVisualCol;1496 }14971498 const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol!; // U1499 if (targetTooShort || targetCantFitPreferred) {1500 // Cases 4 and 51501 return targetMaxVisualCol;1502 }15031504 // Case 31505 const result = this.preferredVisualCol!;1506 this.preferredVisualCol = null;1507 return result;1508 }15091510 private moveToLineStart(): void {1511 this.lastAction = null;1512 this.setCursorCol(0);1513 }15141515 private moveToLineEnd(): void {1516 this.lastAction = null;1517 const currentLine = this.state.lines[this.state.cursorLine] || "";1518 this.setCursorCol(currentLine.length);1519 }15201521 private deleteToStartOfLine(): void {1522 this.exitHistoryBrowsing();15231524 const currentLine = this.state.lines[this.state.cursorLine] || "";15251526 if (this.state.cursorCol > 0) {1527 this.pushUndoSnapshot();15281529 // 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";15331534 // Delete from start of line up to cursor1535 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();15391540 // At start of line - merge with previous line, treating newline as deleted text1541 this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });1542 this.lastAction = "kill";15431544 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 }15501551 if (this.onChange) {1552 this.onChange(this.getText());1553 }1554 }15551556 private deleteToEndOfLine(): void {1557 this.exitHistoryBrowsing();15581559 const currentLine = this.state.lines[this.state.cursorLine] || "";15601561 if (this.state.cursorCol < currentLine.length) {1562 this.pushUndoSnapshot();15631564 // 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";15681569 // Delete from cursor to end of line1570 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();15731574 // At end of line - merge with next line, treating newline as deleted text1575 this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });1576 this.lastAction = "kill";15771578 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 }15821583 if (this.onChange) {1584 this.onChange(this.getText());1585 }1586 }15871588 private deleteWordBackwards(): void {1589 this.exitHistoryBrowsing();15901591 const currentLine = this.state.lines[this.state.cursorLine] || "";15921593 // 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();15971598 // Treat newline as deleted text (backward deletion = prepend)1599 this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });1600 this.lastAction = "kill";16011602 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();16101611 // Save lastAction before cursor movement (moveWordBackwards resets it)1612 const wasKill = this.lastAction === "kill";16131614 const oldCursorCol = this.state.cursorCol;1615 this.moveWordBackwards();1616 const deleteFrom = this.state.cursorCol;1617 this.setCursorCol(oldCursorCol);16181619 const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol);1620 this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });1621 this.lastAction = "kill";16221623 this.state.lines[this.state.cursorLine] =1624 currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);1625 this.setCursorCol(deleteFrom);1626 }16271628 if (this.onChange) {1629 this.onChange(this.getText());1630 }1631 }16321633 private deleteWordForward(): void {1634 this.exitHistoryBrowsing();16351636 const currentLine = this.state.lines[this.state.cursorLine] || "";16371638 // 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();16421643 // Treat newline as deleted text (forward deletion = append)1644 this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });1645 this.lastAction = "kill";16461647 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();16531654 // Save lastAction before cursor movement (moveWordForwards resets it)1655 const wasKill = this.lastAction === "kill";16561657 const oldCursorCol = this.state.cursorCol;1658 this.moveWordForwards();1659 const deleteTo = this.state.cursorCol;1660 this.setCursorCol(oldCursorCol);16611662 const deletedText = currentLine.slice(this.state.cursorCol, deleteTo);1663 this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });1664 this.lastAction = "kill";16651666 this.state.lines[this.state.cursorLine] =1667 currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo);1668 }16691670 if (this.onChange) {1671 this.onChange(this.getText());1672 }1673 }16741675 private handleForwardDelete(): void {1676 this.exitHistoryBrowsing();1677 this.lastAction = null;16781679 const currentLine = this.state.lines[this.state.cursorLine] || "";16801681 if (this.state.cursorCol < currentLine.length) {1682 this.pushUndoSnapshot();16831684 // Delete grapheme at cursor position (handles emojis, combining characters, etc.)1685 const afterCursor = currentLine.slice(this.state.cursorCol);16861687 // Find the first grapheme at cursor1688 const graphemes = [...this.segment(afterCursor, "grapheme")];1689 const firstGrapheme = graphemes[0];1690 const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;16911692 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();16971698 // At end of line - merge with next line1699 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 }17031704 if (this.onChange) {1705 this.onChange(this.getText());1706 }17071708 // Update or re-trigger autocomplete after forward delete1709 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 context1715 if (this.isInSlashCommandContext(textBeforeCursor)) {1716 this.tryTriggerAutocomplete();1717 }1718 // Symbol-based completion context like @, #, or provider triggers1719 else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {1720 this.tryTriggerAutocomplete();1721 }1722 }1723 }17241725 /**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.lines1729 * - startCol: starting column in the logical line1730 * - length: length of this visual line segment1731 */1732 private buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> {1733 const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = [];17341735 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 line1740 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 wrapping1745 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 }17551756 return visualLines;1757 }17581759 /**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 last1772 // 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 }17801781 /**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 }17891790 private moveCursor(deltaLine: number, deltaCol: number): void {1791 this.lastAction = null;1792 const visualLines = this.buildVisualLineMap(this.lastWidth);1793 const currentVisualLine = this.findCurrentVisualLine(visualLines);17941795 if (deltaLine !== 0) {1796 const targetVisualLine = currentVisualLine + deltaLine;17971798 if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {1799 this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);1800 }1801 }18021803 if (deltaCol !== 0) {1804 const currentLine = this.state.lines[this.state.cursorLine] || "";18051806 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 line1815 this.state.cursorLine++;1816 this.setCursorCol(0);1817 } else {1818 // At end of last line - can't move, but set preferredVisualCol for up/down navigation1819 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 line1833 this.state.cursorLine--;1834 const prevLine = this.state.lines[this.state.cursorLine] || "";1835 this.setCursorCol(prevLine.length);1836 }1837 }1838 }18391840 // Keep an open autocomplete picker in sync with the new cursor1841 // position: cursor movement changes the text before the cursor, so a1842 // picker computed for the old position is stale. Re-query so it1843 // refreshes — or closes when the new position yields no suggestions —1844 // mirroring insertCharacter()/handleBackspace(). Without this, arrowing1845 // left from `/cmd ` back into the command name leaves the argument1846 // picker showing against a `/cmd` prefix (and a Tab there would1847 // concatenate the stale suggestion onto the partial command name).1848 if (this.autocompleteState) {1849 this.updateAutocomplete();1850 }1851 }18521853 /**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));18611862 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));18651866 this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);1867 }18681869 private moveWordBackwards(): void {1870 this.lastAction = null;1871 const currentLine = this.state.lines[this.state.cursorLine] || "";18721873 // If at start of line, move to end of previous line1874 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 }18821883 this.setCursorCol(1884 findWordBackward(currentLine, this.state.cursorCol, {1885 segment: (text) => this.segment(text, "word"),1886 isAtomicSegment: isPasteMarker,1887 }),1888 );1889 }18901891 /**1892 * Yank (paste) the most recent kill ring entry at cursor position.1893 */1894 private yank(): void {1895 if (this.killRing.length === 0) return;18961897 this.pushUndoSnapshot();18981899 const text = this.killRing.peek()!;1900 this.insertYankedText(text);19011902 this.lastAction = "yank";1903 }19041905 /**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 entry1911 if (this.lastAction !== "yank" || this.killRing.length <= 1) return;19121913 this.pushUndoSnapshot();19141915 // Delete the previously yanked text (still at end of ring before rotation)1916 this.deleteYankedText();19171918 // Rotate the ring: move end to front1919 this.killRing.rotate();19201921 // Insert the new most recent entry (now at end after rotation)1922 const text = this.killRing.peek()!;1923 this.insertYankedText(text);19241925 this.lastAction = "yank";1926 }19271928 /**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");19341935 if (lines.length === 1) {1936 // Single line - insert at cursor1937 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 insert1944 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);19471948 // First line merges with text before cursor1949 this.state.lines[this.state.cursorLine] = before + (lines[0] || "");19501951 // Insert middle lines1952 for (let i = 1; i < lines.length - 1; i++) {1953 this.state.lines.splice(this.state.cursorLine + i, 0, lines[i] || "");1954 }19551956 // Last line merges with text after cursor1957 const lastLineIndex = this.state.cursorLine + lines.length - 1;1958 this.state.lines.splice(lastLineIndex, 0, (lines[lines.length - 1] || "") + after);19591960 // Update cursor position1961 this.state.cursorLine = lastLineIndex;1962 this.setCursorCol((lines[lines.length - 1] || "").length);1963 }19641965 if (this.onChange) {1966 this.onChange(this.getText());1967 }1968 }19691970 /**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;19771978 const yankLines = yankedText.split("\n");19791980 if (yankLines.length === 1) {1981 // Single line - delete backward from cursor1982 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 line1990 const startLine = this.state.cursorLine - (yankLines.length - 1);1991 const startCol = (this.state.lines[startLine] || "").length - (yankLines[0] || "").length;19921993 // Get text after cursor on current line1994 const afterCursor = (this.state.lines[this.state.cursorLine] || "").slice(this.state.cursorCol);19951996 // Get text before yank start position1997 const beforeYank = (this.state.lines[startLine] || "").slice(0, startCol);19981999 // Remove all lines from startLine to cursorLine and replace with merged line2000 this.state.lines.splice(startLine, yankLines.length, beforeYank + afterCursor);20012002 // Update cursor2003 this.state.cursorLine = startLine;2004 this.setCursorCol(startCol);2005 }20062007 if (this.onChange) {2008 this.onChange(this.getText());2009 }2010 }20112012 private pushUndoSnapshot(): void {2013 this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter });2014 }20152016 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 }20292030 /**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;20382039 const end = isForward ? lines.length : -1;2040 const step = isForward ? 1 : -1;20412042 for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) {2043 const line = lines[lineIdx] || "";2044 const isCurrentLine = lineIdx === this.state.cursorLine;20452046 // Current line: start after/before cursor; other lines: search full line2047 const searchFrom = isCurrentLine2048 ? isForward2049 ? this.state.cursorCol + 12050 : this.state.cursorCol - 12051 : undefined;20522053 const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);20542055 if (idx !== -1) {2056 this.state.cursorLine = lineIdx;2057 this.setCursorCol(idx);2058 return;2059 }2060 }2061 // No match found - cursor stays in place2062 }20632064 private moveWordForwards(): void {2065 this.lastAction = null;2066 const currentLine = this.state.lines[this.state.cursorLine] || "";20672068 // If at end of line, move to start of next line2069 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 }20762077 this.setCursorCol(2078 findWordForward(currentLine, this.state.cursorCol, {2079 segment: (text) => this.segment(text, "word"),2080 isAtomicSegment: isPasteMarker,2081 }),2082 );2083 }20842085 // Slash menu only allowed on the first line of the editor2086 private isSlashMenuAllowed(): boolean {2087 return this.state.cursorLine === 0;2088 }20892090 // 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 }20972098 private isInSlashCommandContext(textBeforeCursor: string): boolean {2099 return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");2100 }21012102 // Autocomplete methods2103 /**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 selected2109 * 2. Prefix match -> first item whose value starts with prefix2110 * 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;21162117 let firstPrefixIndex = -1;21182119 for (let i = 0; i < items.length; i++) {2120 const value = items[i]!.value;2121 if (value === prefix) {2122 return i; // Exact match always wins2123 }2124 if (firstPrefixIndex === -1 && value.startsWith(prefix)) {2125 firstPrefixIndex = i;2126 }2127 }21282129 return firstPrefixIndex;2130 }21312132 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 }21392140 private tryTriggerAutocomplete(explicitTab: boolean = false): void {2141 this.requestAutocomplete({ force: false, explicitTab });2142 }21432144 private handleTabCompletion(): void {2145 if (!this.autocompleteProvider) return;21462147 const currentLine = this.state.lines[this.state.cursorLine] || "";2148 const beforeCursor = currentLine.slice(0, this.state.cursorCol);21492150 if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {2151 this.handleSlashCommandCompletion();2152 } else {2153 this.forceFileAutocomplete(true);2154 }2155 }21562157 private handleSlashCommandCompletion(): void {2158 this.requestAutocomplete({ force: false, explicitTab: true });2159 }21602161 private forceFileAutocomplete(explicitTab: boolean = false): void {2162 this.requestAutocomplete({ force: true, explicitTab });2163 }21642165 private requestAutocomplete(options: { force: boolean; explicitTab: boolean }): void {2166 if (!this.autocompleteProvider) return;21672168 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 }21802181 this.cancelAutocompleteRequest();2182 const startToken = ++this.autocompleteStartToken;21832184 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 }21922193 void this.startAutocompleteRequest(startToken, options);2194 }21952196 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 }22062207 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;22132214 await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options);2215 })();2216 await this.autocompleteRequestTask;2217 }22182219 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 }22312232 private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number {2233 if (options.explicitTab || options.force) {2234 return 0;2235 }22362237 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 }22412242 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;22512252 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 );22582259 if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) {2260 return;2261 }22622263 this.autocompleteAbort = undefined;22642265 if (!suggestions || !Array.isArray(suggestions.items) || suggestions.items.length === 0) {2266 this.cancelAutocomplete();2267 this.tui.requestRender();2268 return;2269 }22702271 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 }22892290 this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular");2291 this.tui.requestRender();2292 }22932294 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 === snapshotCol2307 );2308 }23092310 private applyAutocompleteSuggestions(suggestions: AutocompleteSuggestions, state: "regular" | "force"): void {2311 this.autocompletePrefix = suggestions.prefix;2312 this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);23132314 const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);2315 if (bestMatchIndex >= 0) {2316 this.autocompleteList.setSelectedIndex(bestMatchIndex);2317 }23182319 this.autocompleteState = state;2320 }23212322 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 }23312332 private clearAutocompleteUi(): void {2333 this.autocompleteState = null;2334 this.autocompleteList = undefined;2335 this.autocompletePrefix = "";2336 }23372338 private cancelAutocomplete(): void {2339 this.cancelAutocompleteRequest();2340 this.clearAutocompleteUi();2341 }23422343 public isShowingAutocomplete(): boolean {2344 return this.autocompleteState !== null;2345 }23462347 private updateAutocomplete(): void {2348 if (!this.autocompleteState || !this.autocompleteProvider) return;2349 this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false });2350 }2351}2352