feat: add word-based navigation and deletion shortcuts to input (#164)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
// 4. Resolves placeholders on submit
|
// 4. Resolves placeholders on submit
|
||||||
|
|
||||||
// Import useInput from vendored Ink for bracketed paste support
|
// 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 RawTextInput from "ink-text-input";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +33,66 @@ function sanitizeForDisplay(text: string): string {
|
|||||||
return text.replace(/\r\n|\r|\n/g, "↵");
|
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({
|
export function PasteAwareTextInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -42,14 +102,24 @@ export function PasteAwareTextInput({
|
|||||||
cursorPosition,
|
cursorPosition,
|
||||||
onCursorMove,
|
onCursorMove,
|
||||||
}: PasteAwareTextInputProps) {
|
}: PasteAwareTextInputProps) {
|
||||||
|
const { internal_eventEmitter } = useStdin();
|
||||||
const [displayValue, setDisplayValue] = useState(value);
|
const [displayValue, setDisplayValue] = useState(value);
|
||||||
const [actualValue, setActualValue] = useState(value);
|
const [actualValue, setActualValue] = useState(value);
|
||||||
const lastPasteDetectedAtRef = useRef<number>(0);
|
const lastPasteDetectedAtRef = useRef<number>(0);
|
||||||
const suppressNextChangeRef = useRef<boolean>(false);
|
|
||||||
const caretOffsetRef = useRef<number>((value || "").length);
|
const caretOffsetRef = useRef<number>((value || "").length);
|
||||||
const [nudgeCursorOffset, setNudgeCursorOffset] = useState<
|
const [nudgeCursorOffset, setNudgeCursorOffset] = useState<
|
||||||
number | undefined
|
number | undefined
|
||||||
>(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
|
// Apply cursor position from parent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -158,12 +228,111 @@ export function PasteAwareTextInput({
|
|||||||
{ isActive: focus },
|
{ 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) => {
|
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
|
// Heuristic: detect large additions that look like pastes
|
||||||
const addedLen = newValue.length - displayValue.length;
|
const addedLen = newValue.length - displayValue.length;
|
||||||
const lineDelta = countLines(newValue) - countLines(displayValue);
|
const lineDelta = countLines(newValue) - countLines(displayValue);
|
||||||
|
|||||||
50
vendor/ink-text-input/build/index.js
vendored
50
vendor/ink-text-input/build/index.js
vendored
@@ -2,6 +2,31 @@ import chalk from 'chalk';
|
|||||||
import { Text, useInput } from 'ink';
|
import { Text, useInput } from 'ink';
|
||||||
import React, { useEffect, useState } from 'react';
|
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 }) {
|
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 [state, setState] = useState({ cursorOffset: (originalValue || '').length, cursorWidth: 0 });
|
||||||
const { cursorOffset, cursorWidth } = state;
|
const { cursorOffset, cursorWidth } = state;
|
||||||
@@ -42,11 +67,8 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
if (key && key.isPasted) {
|
// Filter control sequences (escape keys, Option+Arrow garbage, etc.)
|
||||||
return;
|
if (isControlSequence(input, key)) {
|
||||||
}
|
|
||||||
// Treat Escape as a control key (don't insert into value)
|
|
||||||
if (key.escape || (key.ctrl && input === 'c') || key.tab || (key.shift && key.tab)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.return) {
|
if (key.return) {
|
||||||
@@ -58,22 +80,24 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask,
|
|||||||
let nextCursorOffset = cursorOffset;
|
let nextCursorOffset = cursorOffset;
|
||||||
let nextValue = originalValue;
|
let nextValue = originalValue;
|
||||||
let nextCursorWidth = 0;
|
let nextCursorWidth = 0;
|
||||||
if (key.leftArrow) {
|
if (key.leftArrow || key.rightArrow) {
|
||||||
if (showCursor) {
|
// Skip if meta is pressed - Option+Arrow is handled by parent for word navigation
|
||||||
nextCursorOffset--;
|
if (key.meta) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else if (key.rightArrow) {
|
|
||||||
if (showCursor) {
|
if (showCursor) {
|
||||||
nextCursorOffset++;
|
nextCursorOffset += key.leftArrow ? -1 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (key.upArrow || key.downArrow) {
|
else if (key.upArrow || key.downArrow) {
|
||||||
// Handle wrapped line navigation - don't handle here, let parent decide
|
// Let parent decide (wrapped line navigation)
|
||||||
// Parent will check cursor position to determine if at boundary
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
else if (key.backspace || key.delete) {
|
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) {
|
if (cursorOffset > 0) {
|
||||||
nextValue = originalValue.slice(0, cursorOffset - 1) + originalValue.slice(cursorOffset, originalValue.length);
|
nextValue = originalValue.slice(0, cursorOffset - 1) + originalValue.slice(cursorOffset, originalValue.length);
|
||||||
nextCursorOffset--;
|
nextCursorOffset--;
|
||||||
|
|||||||
Reference in New Issue
Block a user