From 723dd17d473fe68f23310fb41610be50daeb4283 Mon Sep 17 00:00:00 2001 From: jnjpng Date: Mon, 8 Dec 2025 17:14:43 -0800 Subject: [PATCH] feat: add word-based navigation and deletion shortcuts to input (#164) Co-authored-by: Letta --- src/cli/components/PasteAwareTextInput.tsx | 183 ++++++++++++++++++++- vendor/ink-text-input/build/index.js | 50 ++++-- 2 files changed, 213 insertions(+), 20 deletions(-) diff --git a/src/cli/components/PasteAwareTextInput.tsx b/src/cli/components/PasteAwareTextInput.tsx index 69e66a3..ae8d826 100644 --- a/src/cli/components/PasteAwareTextInput.tsx +++ b/src/cli/components/PasteAwareTextInput.tsx @@ -5,7 +5,7 @@ // 4. Resolves placeholders on submit // Import useInput from vendored Ink for bracketed paste support -import { useInput } from "ink"; +import { useInput, useStdin } from "ink"; import RawTextInput from "ink-text-input"; import { useEffect, useRef, useState } from "react"; import { @@ -33,6 +33,66 @@ function sanitizeForDisplay(text: string): string { return text.replace(/\r\n|\r|\n/g, "↵"); } +/** Find the boundary of the previous word for option+left navigation */ +function findPreviousWordBoundary(text: string, cursorPos: number): number { + if (cursorPos === 0) return 0; + + // Move back one position if we're at the end of a word + let pos = cursorPos - 1; + + // Skip whitespace backwards + while (pos > 0 && /\s/.test(text.charAt(pos))) { + pos--; + } + + // Skip word characters backwards + while (pos > 0 && /\S/.test(text.charAt(pos))) { + pos--; + } + + // If we stopped at whitespace, move forward one + if (pos > 0 && /\s/.test(text.charAt(pos))) { + pos++; + } + + return Math.max(0, pos); +} + +/** Find the boundary of the next word for option+right navigation */ +function findNextWordBoundary(text: string, cursorPos: number): number { + if (cursorPos >= text.length) return text.length; + + let pos = cursorPos; + + // Skip current word forward + while (pos < text.length && /\S/.test(text.charAt(pos))) { + pos++; + } + + // Skip whitespace forward + while (pos < text.length && /\s/.test(text.charAt(pos))) { + pos++; + } + + return pos; +} + +type WordDirection = "left" | "right"; + +// biome-ignore lint/suspicious/noControlCharactersInRegex: Terminal escape sequences require ESC control character +const OPTION_LEFT_PATTERN = /^\u001b\[(?:1;)?(?:3|4|7|8|9)D$/; +// biome-ignore lint/suspicious/noControlCharactersInRegex: Terminal escape sequences require ESC control character +const OPTION_RIGHT_PATTERN = /^\u001b\[(?:1;)?(?:3|4|7|8|9)C$/; + +function detectOptionWordDirection(sequence: string): WordDirection | null { + if (!sequence.startsWith("\u001b")) return null; + if (sequence === "\u001bb" || sequence === "\u001bB") return "left"; + if (sequence === "\u001bf" || sequence === "\u001bF") return "right"; + if (OPTION_LEFT_PATTERN.test(sequence)) return "left"; + if (OPTION_RIGHT_PATTERN.test(sequence)) return "right"; + return null; +} + export function PasteAwareTextInput({ value, onChange, @@ -42,14 +102,24 @@ export function PasteAwareTextInput({ cursorPosition, onCursorMove, }: PasteAwareTextInputProps) { + const { internal_eventEmitter } = useStdin(); const [displayValue, setDisplayValue] = useState(value); const [actualValue, setActualValue] = useState(value); const lastPasteDetectedAtRef = useRef(0); - const suppressNextChangeRef = useRef(false); const caretOffsetRef = useRef((value || "").length); const [nudgeCursorOffset, setNudgeCursorOffset] = useState< number | undefined >(undefined); + const displayValueRef = useRef(displayValue); + const focusRef = useRef(focus); + + useEffect(() => { + displayValueRef.current = displayValue; + }, [displayValue]); + + useEffect(() => { + focusRef.current = focus; + }, [focus]); // Apply cursor position from parent useEffect(() => { @@ -158,12 +228,111 @@ export function PasteAwareTextInput({ { isActive: focus }, ); + // Store onChange in a ref to avoid stale closures in event handlers + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + // Consolidated raw stdin handler for Option+Arrow navigation and Option+Delete + // Uses internal_eventEmitter (Ink's private API) for escape sequences that useInput doesn't parse correctly. + // Falls back gracefully if internal_eventEmitter is unavailable (useInput handler above still works for some cases). + useEffect(() => { + if (!internal_eventEmitter) return undefined; + + const moveCursorToPreviousWord = () => { + const newPos = findPreviousWordBoundary( + displayValueRef.current, + caretOffsetRef.current, + ); + setNudgeCursorOffset(newPos); + caretOffsetRef.current = newPos; + }; + + const moveCursorToNextWord = () => { + const newPos = findNextWordBoundary( + displayValueRef.current, + caretOffsetRef.current, + ); + setNudgeCursorOffset(newPos); + caretOffsetRef.current = newPos; + }; + + const deletePreviousWord = () => { + const curPos = caretOffsetRef.current; + const wordStart = findPreviousWordBoundary( + displayValueRef.current, + curPos, + ); + if (wordStart === curPos) return; + + const newDisplay = + displayValueRef.current.slice(0, wordStart) + + displayValueRef.current.slice(curPos); + const resolvedActual = resolvePlaceholders(newDisplay); + + setDisplayValue(newDisplay); + setActualValue(resolvedActual); + onChangeRef.current(newDisplay); + setNudgeCursorOffset(wordStart); + caretOffsetRef.current = wordStart; + }; + + const handleRawInput = (payload: unknown) => { + if (!focusRef.current) return; + + // Extract sequence from payload (may be string or object with sequence property) + let sequence: string | null = null; + if (typeof payload === "string") { + sequence = payload; + } else if ( + payload && + typeof payload === "object" && + typeof (payload as { sequence?: unknown }).sequence === "string" + ) { + sequence = (payload as { sequence?: string }).sequence ?? null; + } + if (!sequence) return; + + // Option+Delete sequences (check first as they're exact matches) + // - iTerm2/some terminals: ESC + DEL (\x1b\x7f) + // - Some terminals: ESC + Backspace (\x1b\x08) + // - Warp: Ctrl+W (\x17) + // Note: macOS Terminal sends plain \x7f (same as regular delete) - no modifier info + if ( + sequence === "\x1b\x7f" || + sequence === "\x1b\x08" || + sequence === "\x1b\b" || + sequence === "\x17" + ) { + deletePreviousWord(); + return; + } + + // Option+Arrow navigation (only process escape sequences) + if (sequence.length <= 32 && sequence.includes("\u001b")) { + const parts = sequence.split("\u001b"); + for (let i = 1; i < parts.length; i++) { + const dir = detectOptionWordDirection(`\u001b${parts[i]}`); + if (dir === "left") { + moveCursorToPreviousWord(); + return; + } + if (dir === "right") { + moveCursorToNextWord(); + return; + } + } + } + }; + + internal_eventEmitter.prependListener("input", handleRawInput); + return () => { + internal_eventEmitter.removeListener("input", handleRawInput); + }; + }, [internal_eventEmitter]); + const handleChange = (newValue: string) => { - // If we just handled a paste via useInput, ignore this immediate change - if (suppressNextChangeRef.current) { - suppressNextChangeRef.current = false; - return; - } // Heuristic: detect large additions that look like pastes const addedLen = newValue.length - displayValue.length; const lineDelta = countLines(newValue) - countLines(displayValue); diff --git a/vendor/ink-text-input/build/index.js b/vendor/ink-text-input/build/index.js index 3ac2210..bd00799 100644 --- a/vendor/ink-text-input/build/index.js +++ b/vendor/ink-text-input/build/index.js @@ -2,6 +2,31 @@ import chalk from 'chalk'; import { Text, useInput } from 'ink'; import React, { useEffect, useState } from 'react'; +/** + * Determines if the input should be treated as a control sequence (not inserted as text). + * This centralizes escape sequence filtering to prevent garbage characters from being inserted. + */ +function isControlSequence(input, key) { + // Pasted content is handled separately + if (key?.isPasted) return true; + + // Standard control keys (but NOT plain escape - apps may need it for shortcuts) + if (key.tab || (key.ctrl && input === 'c')) return true; + if (key.shift && key.tab) return true; + + // Ctrl+W (delete word) - handled by parent component + if (key.ctrl && (input === 'w' || input === 'W')) return true; + + // Option+Arrow escape sequences: Ink parses \x1bb as meta=true, input='b' + if (key.meta && (input === 'b' || input === 'B' || input === 'f' || input === 'F')) return true; + + // Filter specific escape sequences that would insert garbage, but allow plain ESC through + // CSI sequences (ESC[...), Option+Delete (ESC + DEL), and other multi-char escape sequences + if (input && typeof input === 'string' && input.startsWith('\x1b') && input.length > 1) return true; + + return false; +} + function TextInput({ value: originalValue, placeholder = '', focus = true, mask, highlightPastedText = false, showCursor = true, onChange, onSubmit, externalCursorOffset, onCursorOffsetChange }) { const [state, setState] = useState({ cursorOffset: (originalValue || '').length, cursorWidth: 0 }); const { cursorOffset, cursorWidth } = state; @@ -42,11 +67,8 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask, } } useInput((input, key) => { - if (key && key.isPasted) { - return; - } - // Treat Escape as a control key (don't insert into value) - if (key.escape || (key.ctrl && input === 'c') || key.tab || (key.shift && key.tab)) { + // Filter control sequences (escape keys, Option+Arrow garbage, etc.) + if (isControlSequence(input, key)) { return; } if (key.return) { @@ -58,22 +80,24 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask, let nextCursorOffset = cursorOffset; let nextValue = originalValue; let nextCursorWidth = 0; - if (key.leftArrow) { - if (showCursor) { - nextCursorOffset--; + if (key.leftArrow || key.rightArrow) { + // Skip if meta is pressed - Option+Arrow is handled by parent for word navigation + if (key.meta) { + return; } - } - else if (key.rightArrow) { if (showCursor) { - nextCursorOffset++; + nextCursorOffset += key.leftArrow ? -1 : 1; } } else if (key.upArrow || key.downArrow) { - // Handle wrapped line navigation - don't handle here, let parent decide - // Parent will check cursor position to determine if at boundary + // Let parent decide (wrapped line navigation) return; } else if (key.backspace || key.delete) { + // Skip if meta is pressed - Option+Delete is handled by parent for word deletion + if (key.meta) { + return; + } if (cursorOffset > 0) { nextValue = originalValue.slice(0, cursorOffset - 1) + originalValue.slice(cursorOffset, originalValue.length); nextCursorOffset--;