fix: add left/right arrow key cursor navigation in approval text inputs (#489)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { useTextInputCursor } from "../hooks/useTextInputCursor";
|
||||
import { colors } from "./colors";
|
||||
|
||||
type BashInfo = {
|
||||
@@ -41,7 +42,12 @@ export const InlineBashApproval = memo(
|
||||
allowPersistence = true,
|
||||
}: Props) => {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [customReason, setCustomReason] = useState("");
|
||||
const {
|
||||
text: customReason,
|
||||
cursorPos,
|
||||
handleKey,
|
||||
clear,
|
||||
} = useTextInputCursor();
|
||||
const columns = useTerminalWidth();
|
||||
|
||||
// Custom option index depends on whether "always" option is shown
|
||||
@@ -75,31 +81,20 @@ export const InlineBashApproval = memo(
|
||||
if (isOnCustomOption) {
|
||||
if (key.return) {
|
||||
if (customReason.trim()) {
|
||||
// User typed a reason - send it
|
||||
onDeny(customReason.trim());
|
||||
}
|
||||
// If empty, do nothing (can't submit empty reason)
|
||||
return;
|
||||
}
|
||||
if (key.escape) {
|
||||
if (customReason) {
|
||||
// Clear text first
|
||||
setCustomReason("");
|
||||
clear();
|
||||
} else {
|
||||
// No text, cancel (queue denial, return to input)
|
||||
onCancel?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setCustomReason((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
// Printable characters - append to custom reason
|
||||
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||
setCustomReason((prev) => prev + input);
|
||||
}
|
||||
return;
|
||||
// Handle text input (arrows, backspace, typing)
|
||||
if (handleKey(input, key)) return;
|
||||
}
|
||||
|
||||
// When on regular options
|
||||
@@ -223,8 +218,9 @@ export const InlineBashApproval = memo(
|
||||
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||
{customReason ? (
|
||||
<Text wrap="wrap">
|
||||
{customReason}
|
||||
{customReason.slice(0, cursorPos)}
|
||||
{isOnCustomOption && "█"}
|
||||
{customReason.slice(cursorPos)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text wrap="wrap" dimColor>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AdvancedDiffSuccess } from "../helpers/diff";
|
||||
import { parsePatchToAdvancedDiff } from "../helpers/diff";
|
||||
import { parsePatchOperations } from "../helpers/formatArgsDisplay";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { useTextInputCursor } from "../hooks/useTextInputCursor";
|
||||
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
|
||||
import { colors } from "./colors";
|
||||
|
||||
@@ -157,7 +158,12 @@ export const InlineFileEditApproval = memo(
|
||||
allowPersistence = true,
|
||||
}: Props) => {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [customReason, setCustomReason] = useState("");
|
||||
const {
|
||||
text: customReason,
|
||||
cursorPos,
|
||||
handleKey,
|
||||
clear,
|
||||
} = useTextInputCursor();
|
||||
const columns = useTerminalWidth();
|
||||
|
||||
// Custom option index depends on whether "always" option is shown
|
||||
@@ -241,20 +247,14 @@ export const InlineFileEditApproval = memo(
|
||||
}
|
||||
if (key.escape) {
|
||||
if (customReason) {
|
||||
setCustomReason("");
|
||||
clear();
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setCustomReason((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||
setCustomReason((prev) => prev + input);
|
||||
}
|
||||
return;
|
||||
// Handle text input (arrows, backspace, typing)
|
||||
if (handleKey(input, key)) return;
|
||||
}
|
||||
|
||||
// When on regular options
|
||||
@@ -494,8 +494,9 @@ export const InlineFileEditApproval = memo(
|
||||
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||
{customReason ? (
|
||||
<Text wrap="wrap">
|
||||
{customReason}
|
||||
{customReason.slice(0, cursorPos)}
|
||||
{isOnCustomOption && "█"}
|
||||
{customReason.slice(cursorPos)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text wrap="wrap" dimColor>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { useTextInputCursor } from "../hooks/useTextInputCursor";
|
||||
import { colors } from "./colors";
|
||||
|
||||
type Props = {
|
||||
@@ -55,7 +56,12 @@ export const InlineGenericApproval = memo(
|
||||
allowPersistence = true,
|
||||
}: Props) => {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [customReason, setCustomReason] = useState("");
|
||||
const {
|
||||
text: customReason,
|
||||
cursorPos,
|
||||
handleKey,
|
||||
clear,
|
||||
} = useTextInputCursor();
|
||||
const columns = useTerminalWidth();
|
||||
|
||||
// Custom option index depends on whether "always" option is shown
|
||||
@@ -95,20 +101,14 @@ export const InlineGenericApproval = memo(
|
||||
}
|
||||
if (key.escape) {
|
||||
if (customReason) {
|
||||
setCustomReason("");
|
||||
clear();
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setCustomReason((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||
setCustomReason((prev) => prev + input);
|
||||
}
|
||||
return;
|
||||
// Handle text input (arrows, backspace, typing)
|
||||
if (handleKey(input, key)) return;
|
||||
}
|
||||
|
||||
// When on regular options
|
||||
@@ -230,8 +230,9 @@ export const InlineGenericApproval = memo(
|
||||
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||
{customReason ? (
|
||||
<Text wrap="wrap">
|
||||
{customReason}
|
||||
{customReason.slice(0, cursorPos)}
|
||||
{isOnCustomOption && "█"}
|
||||
{customReason.slice(cursorPos)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text wrap="wrap" dimColor>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { useTextInputCursor } from "../hooks/useTextInputCursor";
|
||||
import { colors } from "./colors";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay";
|
||||
|
||||
@@ -33,7 +34,12 @@ export const InlinePlanApproval = memo(
|
||||
isFocused = true,
|
||||
}: Props) => {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [customReason, setCustomReason] = useState("");
|
||||
const {
|
||||
text: customReason,
|
||||
cursorPos,
|
||||
handleKey,
|
||||
clear,
|
||||
} = useTextInputCursor();
|
||||
const columns = useTerminalWidth();
|
||||
|
||||
const customOptionIndex = 2;
|
||||
@@ -72,21 +78,14 @@ export const InlinePlanApproval = memo(
|
||||
}
|
||||
if (key.escape) {
|
||||
if (customReason) {
|
||||
setCustomReason("");
|
||||
clear();
|
||||
} else {
|
||||
// Esc without text - just clear, stay on planning
|
||||
onKeepPlanning("User cancelled");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setCustomReason((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||
setCustomReason((prev) => prev + input);
|
||||
}
|
||||
return;
|
||||
// Handle text input (arrows, backspace, typing)
|
||||
if (handleKey(input, key)) return;
|
||||
}
|
||||
|
||||
// When on regular options
|
||||
@@ -212,8 +211,9 @@ export const InlinePlanApproval = memo(
|
||||
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||
{customReason ? (
|
||||
<Text wrap="wrap">
|
||||
{customReason}
|
||||
{customReason.slice(0, cursorPos)}
|
||||
{isOnCustomOption && "█"}
|
||||
{customReason.slice(cursorPos)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text wrap="wrap" dimColor>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { Fragment, memo, useMemo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { useTextInputCursor } from "../hooks/useTextInputCursor";
|
||||
import { colors } from "./colors";
|
||||
|
||||
interface QuestionOption {
|
||||
@@ -30,7 +31,14 @@ export const InlineQuestionApproval = memo(
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [customText, setCustomText] = useState("");
|
||||
const {
|
||||
text: customText,
|
||||
setText: setCustomText,
|
||||
cursorPos,
|
||||
setCursorPos,
|
||||
handleKey,
|
||||
clear: clearCustomText,
|
||||
} = useTextInputCursor();
|
||||
const [selectedMulti, setSelectedMulti] = useState<Set<number>>(new Set());
|
||||
const columns = useTerminalWidth();
|
||||
|
||||
@@ -69,7 +77,7 @@ export const InlineQuestionApproval = memo(
|
||||
if (currentQuestionIndex < questions.length - 1) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex + 1);
|
||||
setSelectedOption(0);
|
||||
setCustomText("");
|
||||
clearCustomText();
|
||||
setSelectedMulti(new Set());
|
||||
} else {
|
||||
onSubmit(newAnswers);
|
||||
@@ -121,7 +129,7 @@ export const InlineQuestionApproval = memo(
|
||||
return;
|
||||
}
|
||||
if (input === " " && currentQuestion.multiSelect) {
|
||||
// Space: if not checked, toggle + insert space. If already checked, just insert space.
|
||||
// Space in multi-select: toggle checkbox if not checked, then insert space
|
||||
if (!selectedMulti.has(customOptionIndex)) {
|
||||
setSelectedMulti((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -129,26 +137,23 @@ export const InlineQuestionApproval = memo(
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
// Always insert the space character
|
||||
setCustomText((prev) => `${prev} `);
|
||||
// Insert space at cursor position
|
||||
setCustomText(
|
||||
(prev) => `${prev.slice(0, cursorPos)} ${prev.slice(cursorPos)}`,
|
||||
);
|
||||
setCursorPos((prev) => prev + 1);
|
||||
return;
|
||||
}
|
||||
if (key.escape) {
|
||||
if (customText) {
|
||||
setCustomText("");
|
||||
clearCustomText();
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setCustomText((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||
setCustomText((prev) => prev + input);
|
||||
}
|
||||
return;
|
||||
// Handle text input (arrows, backspace, typing)
|
||||
if (handleKey(input, key)) return;
|
||||
}
|
||||
|
||||
// When on Submit option (multi-select only)
|
||||
@@ -364,8 +369,9 @@ export const InlineQuestionApproval = memo(
|
||||
// Custom input option ("Type something")
|
||||
customText ? (
|
||||
<Text wrap="wrap">
|
||||
{customText}
|
||||
{customText.slice(0, cursorPos)}
|
||||
{isSelected && "█"}
|
||||
{customText.slice(cursorPos)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text wrap="wrap" dimColor>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { useTextInputCursor } from "../hooks/useTextInputCursor";
|
||||
import { colors } from "./colors";
|
||||
|
||||
type Props = {
|
||||
@@ -47,7 +48,12 @@ export const InlineTaskApproval = memo(
|
||||
allowPersistence = true,
|
||||
}: Props) => {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [customReason, setCustomReason] = useState("");
|
||||
const {
|
||||
text: customReason,
|
||||
cursorPos,
|
||||
handleKey,
|
||||
clear,
|
||||
} = useTextInputCursor();
|
||||
const columns = useTerminalWidth();
|
||||
|
||||
// Custom option index depends on whether "always" option is shown
|
||||
@@ -87,20 +93,14 @@ export const InlineTaskApproval = memo(
|
||||
}
|
||||
if (key.escape) {
|
||||
if (customReason) {
|
||||
setCustomReason("");
|
||||
clear();
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setCustomReason((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||
setCustomReason((prev) => prev + input);
|
||||
}
|
||||
return;
|
||||
// Handle text input (arrows, backspace, typing)
|
||||
if (handleKey(input, key)) return;
|
||||
}
|
||||
|
||||
// When on regular options
|
||||
@@ -272,8 +272,9 @@ export const InlineTaskApproval = memo(
|
||||
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||
{customReason ? (
|
||||
<Text wrap="wrap">
|
||||
{customReason}
|
||||
{customReason.slice(0, cursorPos)}
|
||||
{isOnCustomOption && "█"}
|
||||
{customReason.slice(cursorPos)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text wrap="wrap" dimColor>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { useTextInputCursor } from "../hooks/useTextInputCursor";
|
||||
import { colors } from "./colors";
|
||||
|
||||
type Props = {
|
||||
@@ -29,7 +30,12 @@ export const StaticPlanApproval = memo(
|
||||
isFocused = true,
|
||||
}: Props) => {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [customReason, setCustomReason] = useState("");
|
||||
const {
|
||||
text: customReason,
|
||||
cursorPos,
|
||||
handleKey,
|
||||
clear,
|
||||
} = useTextInputCursor();
|
||||
const columns = useTerminalWidth();
|
||||
|
||||
const customOptionIndex = 2;
|
||||
@@ -68,20 +74,14 @@ export const StaticPlanApproval = memo(
|
||||
}
|
||||
if (key.escape) {
|
||||
if (customReason) {
|
||||
setCustomReason("");
|
||||
clear();
|
||||
} else {
|
||||
onKeepPlanning("User cancelled");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setCustomReason((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||
setCustomReason((prev) => prev + input);
|
||||
}
|
||||
return;
|
||||
// Handle text input (arrows, backspace, typing)
|
||||
if (handleKey(input, key)) return;
|
||||
}
|
||||
|
||||
// When on regular options
|
||||
@@ -174,8 +174,9 @@ export const StaticPlanApproval = memo(
|
||||
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||
{customReason ? (
|
||||
<Text wrap="wrap">
|
||||
{customReason}
|
||||
{customReason.slice(0, cursorPos)}
|
||||
{isOnCustomOption && "█"}
|
||||
{customReason.slice(cursorPos)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text wrap="wrap" dimColor>
|
||||
|
||||
78
src/cli/hooks/useTextInputCursor.ts
Normal file
78
src/cli/hooks/useTextInputCursor.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface Key {
|
||||
leftArrow?: boolean;
|
||||
rightArrow?: boolean;
|
||||
backspace?: boolean;
|
||||
delete?: boolean;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing text input with cursor position tracking.
|
||||
*
|
||||
* Handles:
|
||||
* - Left/right arrow key navigation within text
|
||||
* - Backspace at cursor position (not just end)
|
||||
* - Character insertion at cursor position
|
||||
*
|
||||
* @returns Object with text state, cursor position, key handler, and clear function
|
||||
*/
|
||||
export function useTextInputCursor(initialText = "") {
|
||||
const [text, setText] = useState(initialText);
|
||||
const [cursorPos, setCursorPos] = useState(0);
|
||||
|
||||
/**
|
||||
* Handle keyboard input for text editing.
|
||||
* @returns true if the key was handled, false otherwise
|
||||
*/
|
||||
const handleKey = (input: string, key: Key): boolean => {
|
||||
// Arrow key navigation
|
||||
if (key.leftArrow) {
|
||||
setCursorPos((prev) => Math.max(0, prev - 1));
|
||||
return true;
|
||||
}
|
||||
if (key.rightArrow) {
|
||||
setCursorPos((prev) => Math.min(text.length, prev + 1));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Backspace: delete character before cursor
|
||||
if (key.backspace || key.delete) {
|
||||
if (cursorPos > 0) {
|
||||
setText((prev) => prev.slice(0, cursorPos - 1) + prev.slice(cursorPos));
|
||||
setCursorPos((prev) => prev - 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Typing: insert at cursor position
|
||||
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||
setText(
|
||||
(prev) => prev.slice(0, cursorPos) + input + prev.slice(cursorPos),
|
||||
);
|
||||
setCursorPos((prev) => prev + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear text and reset cursor to start
|
||||
*/
|
||||
const clear = () => {
|
||||
setText("");
|
||||
setCursorPos(0);
|
||||
};
|
||||
|
||||
return {
|
||||
text,
|
||||
setText,
|
||||
cursorPos,
|
||||
setCursorPos,
|
||||
handleKey,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user