From 808ed36212e41b2f3a00f39e9cc2f0e48637ba8b Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Wed, 7 Jan 2026 16:07:49 -0800 Subject: [PATCH] fix: add left/right arrow key cursor navigation in approval text inputs (#489) Co-authored-by: Letta --- src/cli/components/InlineBashApproval.tsx | 28 +++---- src/cli/components/InlineFileEditApproval.tsx | 23 +++--- src/cli/components/InlineGenericApproval.tsx | 23 +++--- src/cli/components/InlinePlanApproval.tsx | 24 +++--- src/cli/components/InlineQuestionApproval.tsx | 36 +++++---- src/cli/components/InlineTaskApproval.tsx | 23 +++--- src/cli/components/StaticPlanApproval.tsx | 23 +++--- src/cli/hooks/useTextInputCursor.ts | 78 +++++++++++++++++++ 8 files changed, 171 insertions(+), 87 deletions(-) create mode 100644 src/cli/hooks/useTextInputCursor.ts diff --git a/src/cli/components/InlineBashApproval.tsx b/src/cli/components/InlineBashApproval.tsx index 289ff92..1700615 100644 --- a/src/cli/components/InlineBashApproval.tsx +++ b/src/cli/components/InlineBashApproval.tsx @@ -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( {customReason ? ( - {customReason} + {customReason.slice(0, cursorPos)} {isOnCustomOption && "█"} + {customReason.slice(cursorPos)} ) : ( diff --git a/src/cli/components/InlineFileEditApproval.tsx b/src/cli/components/InlineFileEditApproval.tsx index f6aec8f..b014784 100644 --- a/src/cli/components/InlineFileEditApproval.tsx +++ b/src/cli/components/InlineFileEditApproval.tsx @@ -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( {customReason ? ( - {customReason} + {customReason.slice(0, cursorPos)} {isOnCustomOption && "█"} + {customReason.slice(cursorPos)} ) : ( diff --git a/src/cli/components/InlineGenericApproval.tsx b/src/cli/components/InlineGenericApproval.tsx index 67d43b9..eb54312 100644 --- a/src/cli/components/InlineGenericApproval.tsx +++ b/src/cli/components/InlineGenericApproval.tsx @@ -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( {customReason ? ( - {customReason} + {customReason.slice(0, cursorPos)} {isOnCustomOption && "█"} + {customReason.slice(cursorPos)} ) : ( diff --git a/src/cli/components/InlinePlanApproval.tsx b/src/cli/components/InlinePlanApproval.tsx index c2c9f85..9eca1c0 100644 --- a/src/cli/components/InlinePlanApproval.tsx +++ b/src/cli/components/InlinePlanApproval.tsx @@ -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( {customReason ? ( - {customReason} + {customReason.slice(0, cursorPos)} {isOnCustomOption && "█"} + {customReason.slice(cursorPos)} ) : ( diff --git a/src/cli/components/InlineQuestionApproval.tsx b/src/cli/components/InlineQuestionApproval.tsx index 06f59a4..ec5c881 100644 --- a/src/cli/components/InlineQuestionApproval.tsx +++ b/src/cli/components/InlineQuestionApproval.tsx @@ -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>({}); const [selectedOption, setSelectedOption] = useState(0); - const [customText, setCustomText] = useState(""); + const { + text: customText, + setText: setCustomText, + cursorPos, + setCursorPos, + handleKey, + clear: clearCustomText, + } = useTextInputCursor(); const [selectedMulti, setSelectedMulti] = useState>(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 ? ( - {customText} + {customText.slice(0, cursorPos)} {isSelected && "█"} + {customText.slice(cursorPos)} ) : ( diff --git a/src/cli/components/InlineTaskApproval.tsx b/src/cli/components/InlineTaskApproval.tsx index facb86c..ae50213 100644 --- a/src/cli/components/InlineTaskApproval.tsx +++ b/src/cli/components/InlineTaskApproval.tsx @@ -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( {customReason ? ( - {customReason} + {customReason.slice(0, cursorPos)} {isOnCustomOption && "█"} + {customReason.slice(cursorPos)} ) : ( diff --git a/src/cli/components/StaticPlanApproval.tsx b/src/cli/components/StaticPlanApproval.tsx index 9fd18b6..a2ff6fb 100644 --- a/src/cli/components/StaticPlanApproval.tsx +++ b/src/cli/components/StaticPlanApproval.tsx @@ -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( {customReason ? ( - {customReason} + {customReason.slice(0, cursorPos)} {isOnCustomOption && "█"} + {customReason.slice(cursorPos)} ) : ( diff --git a/src/cli/hooks/useTextInputCursor.ts b/src/cli/hooks/useTextInputCursor.ts new file mode 100644 index 0000000..5d32907 --- /dev/null +++ b/src/cli/hooks/useTextInputCursor.ts @@ -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, + }; +}