From 397eb5e39093b2a8a03c331aabc34a7cfa508a65 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 29 Dec 2025 21:19:57 -0800 Subject: [PATCH] fix: add consistent CTRL-C and ESC handling to all dialogs (#426) Co-authored-by: Letta --- src/cli/App.tsx | 1 + src/cli/components/AgentSelector.tsx | 6 + src/cli/components/EnterPlanModeDialog.tsx | 12 + src/cli/components/FeedbackDialog.tsx | 6 + src/cli/components/HelpDialog.tsx | 6 + src/cli/components/McpSelector.tsx | 6 + src/cli/components/MemoryViewer.tsx | 6 + src/cli/components/MessageSearch.tsx | 6 + src/cli/components/ModelSelector.tsx | 6 + src/cli/components/NewAgentDialog.tsx | 8 +- src/cli/components/OAuthCodeDialog.tsx | 11 +- src/cli/components/PinDialog.tsx | 6 + src/cli/components/PlanModeDialog.tsx | 6 + src/cli/components/ProfileSelector.tsx | 6 + src/cli/components/QuestionDialog.tsx | 351 +++++++++++--------- src/cli/components/ResumeSelector.tsx | 6 + src/cli/components/SubagentManager.tsx | 8 +- src/cli/components/SystemPromptSelector.tsx | 8 +- src/cli/components/ToolsetSelector.tsx | 8 +- 19 files changed, 302 insertions(+), 171 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index aa531e5..d91d1d3 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -5802,6 +5802,7 @@ Plan file path: ${planFilePath}`; )} diff --git a/src/cli/components/AgentSelector.tsx b/src/cli/components/AgentSelector.tsx index 1f064ab..31742e4 100644 --- a/src/cli/components/AgentSelector.tsx +++ b/src/cli/components/AgentSelector.tsx @@ -63,6 +63,12 @@ export function AgentSelector({ }, []); useInput((input, key) => { + // CTRL-C: immediately cancel (works even during loading/error) + if (key.ctrl && input === "c") { + onCancel(); + return; + } + if (loading || error) return; if (key.upArrow) { diff --git a/src/cli/components/EnterPlanModeDialog.tsx b/src/cli/components/EnterPlanModeDialog.tsx index e704c00..a64cb6e 100644 --- a/src/cli/components/EnterPlanModeDialog.tsx +++ b/src/cli/components/EnterPlanModeDialog.tsx @@ -16,6 +16,18 @@ export const EnterPlanModeDialog = memo(({ onApprove, onReject }: Props) => { ]; useInput((input, key) => { + // CTRL-C: immediately reject (cancel) + if (key.ctrl && input === "c") { + onReject(); + return; + } + + // ESC: reject (cancel) - was missing! + if (key.escape) { + onReject(); + return; + } + if (key.upArrow) { setSelectedOption((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { diff --git a/src/cli/components/FeedbackDialog.tsx b/src/cli/components/FeedbackDialog.tsx index 047a1d3..6df4c38 100644 --- a/src/cli/components/FeedbackDialog.tsx +++ b/src/cli/components/FeedbackDialog.tsx @@ -18,6 +18,12 @@ export function FeedbackDialog({ const [error, setError] = useState(""); useInput((_input, key) => { + // CTRL-C: immediately cancel + if (key.ctrl && _input === "c") { + onCancel(); + return; + } + if (key.escape) { onCancel(); } diff --git a/src/cli/components/HelpDialog.tsx b/src/cli/components/HelpDialog.tsx index ef47124..d00b7c5 100644 --- a/src/cli/components/HelpDialog.tsx +++ b/src/cli/components/HelpDialog.tsx @@ -100,6 +100,12 @@ export function HelpDialog({ onClose }: HelpDialogProps) { useInput( useCallback( (input, key) => { + // CTRL-C: immediately close + if (key.ctrl && input === "c") { + onClose(); + return; + } + if (key.escape) { onClose(); } else if (key.tab) { diff --git a/src/cli/components/McpSelector.tsx b/src/cli/components/McpSelector.tsx index d790b73..4d027dd 100644 --- a/src/cli/components/McpSelector.tsx +++ b/src/cli/components/McpSelector.tsx @@ -292,6 +292,12 @@ export const McpSelector = memo(function McpSelector({ const selectedServer = pageServers[selectedIndex]; useInput((input, key) => { + // CTRL-C: immediately cancel (works even during loading) + if (key.ctrl && input === "c") { + onCancel(); + return; + } + if (loading) return; // Handle delete confirmation mode diff --git a/src/cli/components/MemoryViewer.tsx b/src/cli/components/MemoryViewer.tsx index 0890344..ab2d4a0 100644 --- a/src/cli/components/MemoryViewer.tsx +++ b/src/cli/components/MemoryViewer.tsx @@ -82,6 +82,12 @@ export function MemoryViewer({ ); useInput((input, key) => { + // CTRL-C: immediately close the entire viewer + if (key.ctrl && input === "c") { + onClose(); + return; + } + // ESC: exit detail view or close entirely if (key.escape) { if (detailBlockIndex !== null) { diff --git a/src/cli/components/MessageSearch.tsx b/src/cli/components/MessageSearch.tsx index 326b3c2..035a2a6 100644 --- a/src/cli/components/MessageSearch.tsx +++ b/src/cli/components/MessageSearch.tsx @@ -194,6 +194,12 @@ export function MessageSearch({ onClose }: MessageSearchProps) { const pageResults = results.slice(startIndex, startIndex + DISPLAY_PAGE_SIZE); useInput((input, key) => { + // CTRL-C: immediately close (bypasses search clearing) + if (key.ctrl && input === "c") { + onClose(); + return; + } + if (key.escape) { if (searchInput || activeQuery) { clearSearch(); diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx index 700f68e..7af7475 100644 --- a/src/cli/components/ModelSelector.tsx +++ b/src/cli/components/ModelSelector.tsx @@ -185,6 +185,12 @@ export function ModelSelector({ useInput( (input, key) => { + // CTRL-C: immediately cancel (bypasses search clearing) + if (key.ctrl && input === "c") { + onCancel(); + return; + } + // Handle ESC: clear search first if active, otherwise cancel if (key.escape) { if (searchQuery) { diff --git a/src/cli/components/NewAgentDialog.tsx b/src/cli/components/NewAgentDialog.tsx index 6cfff3b..504838f 100644 --- a/src/cli/components/NewAgentDialog.tsx +++ b/src/cli/components/NewAgentDialog.tsx @@ -14,7 +14,13 @@ export function NewAgentDialog({ onSubmit, onCancel }: NewAgentDialogProps) { const [nameInput, setNameInput] = useState(""); const [error, setError] = useState(""); - useInput((_, key) => { + useInput((input, key) => { + // CTRL-C: immediately cancel + if (key.ctrl && input === "c") { + onCancel(); + return; + } + if (key.escape) { onCancel(); } diff --git a/src/cli/components/OAuthCodeDialog.tsx b/src/cli/components/OAuthCodeDialog.tsx index 7a7d638..ca2bba4 100644 --- a/src/cli/components/OAuthCodeDialog.tsx +++ b/src/cli/components/OAuthCodeDialog.tsx @@ -114,7 +114,16 @@ export const OAuthCodeDialog = memo( }, [onComplete]); // Handle keyboard input - useInput((_input, key) => { + useInput((input, key) => { + // CTRL-C: cancel at any cancelable state + if (key.ctrl && input === "c") { + if (flowState === "waiting_for_code" || flowState === "select_model") { + settingsManager.clearOAuthState(); + onCancel(); + } + return; + } + if (key.escape && flowState === "waiting_for_code") { settingsManager.clearOAuthState(); onCancel(); diff --git a/src/cli/components/PinDialog.tsx b/src/cli/components/PinDialog.tsx index f1562c5..421f1cb 100644 --- a/src/cli/components/PinDialog.tsx +++ b/src/cli/components/PinDialog.tsx @@ -63,6 +63,12 @@ export function PinDialog({ const scopeText = local ? "to this project" : "globally"; useInput((input, key) => { + // CTRL-C: immediately cancel (bypasses mode transitions) + if (key.ctrl && input === "c") { + onCancel(); + return; + } + if (key.escape) { if (mode === "input" && !isDefault) { // Go back to choose mode diff --git a/src/cli/components/PlanModeDialog.tsx b/src/cli/components/PlanModeDialog.tsx index 178c9db..5bdb737 100644 --- a/src/cli/components/PlanModeDialog.tsx +++ b/src/cli/components/PlanModeDialog.tsx @@ -53,6 +53,12 @@ export const PlanModeDialog = memo( ]; useInput((_input, key) => { + // CTRL-C: immediately exit plan approval (closest to cancel) + if (key.ctrl && _input === "c") { + onKeepPlanning("User pressed CTRL-C to cancel"); + return; + } + if (isEnteringReason) { // When entering reason, only handle enter/escape if (key.return) { diff --git a/src/cli/components/ProfileSelector.tsx b/src/cli/components/ProfileSelector.tsx index cdcaaff..a3c503a 100644 --- a/src/cli/components/ProfileSelector.tsx +++ b/src/cli/components/ProfileSelector.tsx @@ -147,6 +147,12 @@ export const ProfileSelector = memo(function ProfileSelector({ const selectedProfile = pageProfiles[selectedIndex]; useInput((input, key) => { + // CTRL-C: immediately cancel (works even during loading) + if (key.ctrl && input === "c") { + onCancel(); + return; + } + if (loading) return; // Handle delete confirmation mode diff --git a/src/cli/components/QuestionDialog.tsx b/src/cli/components/QuestionDialog.tsx index eab770d..d26a798 100644 --- a/src/cli/components/QuestionDialog.tsx +++ b/src/cli/components/QuestionDialog.tsx @@ -18,200 +18,219 @@ interface Question { type Props = { questions: Question[]; onSubmit: (answers: Record) => void; + onCancel?: () => void; }; -export const QuestionDialog = memo(({ questions, onSubmit }: Props) => { - const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); - const [answers, setAnswers] = useState>({}); - const [selectedOption, setSelectedOption] = useState(0); - const [isOtherMode, setIsOtherMode] = useState(false); - const [otherText, setOtherText] = useState(""); - const [selectedMulti, setSelectedMulti] = useState>(new Set()); +export const QuestionDialog = memo( + ({ questions, onSubmit, onCancel }: Props) => { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + const [selectedOption, setSelectedOption] = useState(0); + const [isOtherMode, setIsOtherMode] = useState(false); + const [otherText, setOtherText] = useState(""); + const [selectedMulti, setSelectedMulti] = useState>(new Set()); - const currentQuestion = questions[currentQuestionIndex]; - const optionsWithOther = currentQuestion - ? [ - ...currentQuestion.options, - { label: "Other", description: "Provide a custom response" }, - ] - : []; + const currentQuestion = questions[currentQuestionIndex]; + const optionsWithOther = currentQuestion + ? [ + ...currentQuestion.options, + { label: "Other", description: "Provide a custom response" }, + ] + : []; - const handleSubmitAnswer = (answer: string) => { - if (!currentQuestion) return; - const newAnswers = { - ...answers, - [currentQuestion.question]: answer, - }; - setAnswers(newAnswers); + const handleSubmitAnswer = (answer: string) => { + if (!currentQuestion) return; + const newAnswers = { + ...answers, + [currentQuestion.question]: answer, + }; + setAnswers(newAnswers); - if (currentQuestionIndex < questions.length - 1) { - setCurrentQuestionIndex(currentQuestionIndex + 1); - setSelectedOption(0); - setIsOtherMode(false); - setOtherText(""); - setSelectedMulti(new Set()); - } else { - onSubmit(newAnswers); - } - }; - - useInput((input, key) => { - if (!currentQuestion) return; - - if (isOtherMode) { - if (key.escape) { + if (currentQuestionIndex < questions.length - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setSelectedOption(0); setIsOtherMode(false); setOtherText(""); - } - return; - } - - if (key.upArrow) { - setSelectedOption((prev) => Math.max(0, prev - 1)); - } else if (key.downArrow) { - setSelectedOption((prev) => - Math.min(optionsWithOther.length - 1, prev + 1), - ); - } else if (key.return) { - if (currentQuestion.multiSelect) { - if (selectedOption === optionsWithOther.length - 1) { - setIsOtherMode(true); - } else if (selectedMulti.size > 0) { - const selectedLabels = Array.from(selectedMulti) - .map((i) => optionsWithOther[i]?.label) - .filter(Boolean) - .join(", "); - handleSubmitAnswer(selectedLabels); - } + setSelectedMulti(new Set()); } else { - if (selectedOption === optionsWithOther.length - 1) { - setIsOtherMode(true); - } else { - handleSubmitAnswer(optionsWithOther[selectedOption]?.label || ""); + onSubmit(newAnswers); + } + }; + + useInput((input, key) => { + if (!currentQuestion) return; + + // CTRL-C: immediately cancel (works in any mode) + if (key.ctrl && input === "c") { + if (onCancel) { + onCancel(); } + return; } - } else if (input === " " && currentQuestion.multiSelect) { - if (selectedOption < optionsWithOther.length - 1) { - setSelectedMulti((prev) => { - const newSet = new Set(prev); - if (newSet.has(selectedOption)) { - newSet.delete(selectedOption); - } else { - newSet.add(selectedOption); - } - return newSet; - }); + + if (isOtherMode) { + if (key.escape) { + setIsOtherMode(false); + setOtherText(""); + } + return; } - } else if (input >= "1" && input <= "9") { - const optionIndex = Number.parseInt(input, 10) - 1; - if (optionIndex < optionsWithOther.length) { + + // ESC in main selection mode: cancel the dialog + if (key.escape) { + if (onCancel) { + onCancel(); + } + return; + } + + if (key.upArrow) { + setSelectedOption((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedOption((prev) => + Math.min(optionsWithOther.length - 1, prev + 1), + ); + } else if (key.return) { if (currentQuestion.multiSelect) { - if (optionIndex < optionsWithOther.length - 1) { - setSelectedMulti((prev) => { - const newSet = new Set(prev); - if (newSet.has(optionIndex)) { - newSet.delete(optionIndex); - } else { - newSet.add(optionIndex); - } - return newSet; - }); + if (selectedOption === optionsWithOther.length - 1) { + setIsOtherMode(true); + } else if (selectedMulti.size > 0) { + const selectedLabels = Array.from(selectedMulti) + .map((i) => optionsWithOther[i]?.label) + .filter(Boolean) + .join(", "); + handleSubmitAnswer(selectedLabels); } } else { - if (optionIndex === optionsWithOther.length - 1) { + if (selectedOption === optionsWithOther.length - 1) { setIsOtherMode(true); } else { - handleSubmitAnswer(optionsWithOther[optionIndex]?.label || ""); + handleSubmitAnswer(optionsWithOther[selectedOption]?.label || ""); + } + } + } else if (input === " " && currentQuestion.multiSelect) { + if (selectedOption < optionsWithOther.length - 1) { + setSelectedMulti((prev) => { + const newSet = new Set(prev); + if (newSet.has(selectedOption)) { + newSet.delete(selectedOption); + } else { + newSet.add(selectedOption); + } + return newSet; + }); + } + } else if (input >= "1" && input <= "9") { + const optionIndex = Number.parseInt(input, 10) - 1; + if (optionIndex < optionsWithOther.length) { + if (currentQuestion.multiSelect) { + if (optionIndex < optionsWithOther.length - 1) { + setSelectedMulti((prev) => { + const newSet = new Set(prev); + if (newSet.has(optionIndex)) { + newSet.delete(optionIndex); + } else { + newSet.add(optionIndex); + } + return newSet; + }); + } + } else { + if (optionIndex === optionsWithOther.length - 1) { + setIsOtherMode(true); + } else { + handleSubmitAnswer(optionsWithOther[optionIndex]?.label || ""); + } } } } - } - }); + }); - const handleOtherSubmit = (text: string) => { - handleSubmitAnswer(text); - }; + const handleOtherSubmit = (text: string) => { + handleSubmitAnswer(text); + }; - if (!currentQuestion) return null; + if (!currentQuestion) return null; - return ( - - - - [{currentQuestion.header}]{" "} - {currentQuestion.question} - - - - {questions.length > 1 && ( + return ( + - - Question {currentQuestionIndex + 1} of {questions.length} + + [{currentQuestion.header}]{" "} + {currentQuestion.question} - )} - {isOtherMode ? ( - - Type your response (Esc to cancel): - - > - - - - ) : ( - - {optionsWithOther.map((option, index) => { - const isSelected = index === selectedOption; - const isChecked = selectedMulti.has(index); - const color = isSelected ? colors.approval.header : undefined; - - return ( - - - - {isSelected ? ">" : " "} - - {currentQuestion.multiSelect && - index < optionsWithOther.length - 1 && ( - - [{isChecked ? "x" : " "}] - - )} - - - {index + 1}. {option.label} - - - - {option.description && ( - - {option.description} - - )} - - ); - })} - - + {questions.length > 1 && ( + - {currentQuestion.multiSelect - ? "Space to toggle, Enter to confirm selection" - : `Enter to select, or type 1-${optionsWithOther.length}`} + Question {currentQuestionIndex + 1} of {questions.length} - - )} - - ); -}); + )} + + {isOtherMode ? ( + + Type your response (Esc to cancel): + + > + + + + ) : ( + + {optionsWithOther.map((option, index) => { + const isSelected = index === selectedOption; + const isChecked = selectedMulti.has(index); + const color = isSelected ? colors.approval.header : undefined; + + return ( + + + + {isSelected ? ">" : " "} + + {currentQuestion.multiSelect && + index < optionsWithOther.length - 1 && ( + + [{isChecked ? "x" : " "}] + + )} + + + {index + 1}. {option.label} + + + + {option.description && ( + + {option.description} + + )} + + ); + })} + + + + {currentQuestion.multiSelect + ? "Space to toggle, Enter to confirm selection" + : `Enter to select, or type 1-${optionsWithOther.length}`} + + + + )} + + ); + }, +); QuestionDialog.displayName = "QuestionDialog"; diff --git a/src/cli/components/ResumeSelector.tsx b/src/cli/components/ResumeSelector.tsx index ee067d9..c8a58ee 100644 --- a/src/cli/components/ResumeSelector.tsx +++ b/src/cli/components/ResumeSelector.tsx @@ -415,6 +415,12 @@ export function ResumeSelector({ }, [activeQuery]); useInput((input, key) => { + // CTRL-C: immediately cancel + if (key.ctrl && input === "c") { + onCancel(); + return; + } + // Tab key cycles through tabs if (key.tab) { const currentIndex = TABS.findIndex((t) => t.id === activeTab); diff --git a/src/cli/components/SubagentManager.tsx b/src/cli/components/SubagentManager.tsx index 1082b39..f4f855c 100644 --- a/src/cli/components/SubagentManager.tsx +++ b/src/cli/components/SubagentManager.tsx @@ -63,7 +63,13 @@ export function SubagentManager({ onClose }: SubagentManagerProps) { loadSubagents(); }, []); - useInput((_input, key) => { + useInput((input, key) => { + // CTRL-C: immediately close + if (key.ctrl && input === "c") { + onClose(); + return; + } + if (key.escape || key.return) { onClose(); } diff --git a/src/cli/components/SystemPromptSelector.tsx b/src/cli/components/SystemPromptSelector.tsx index 52df21e..774efe8 100644 --- a/src/cli/components/SystemPromptSelector.tsx +++ b/src/cli/components/SystemPromptSelector.tsx @@ -34,7 +34,13 @@ export function SystemPromptSelector({ const totalItems = visiblePrompts.length + (hasShowAllOption ? 1 : 0); - useInput((_input, key) => { + useInput((input, key) => { + // CTRL-C: immediately cancel + if (key.ctrl && input === "c") { + onCancel(); + return; + } + if (key.upArrow) { setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { diff --git a/src/cli/components/ToolsetSelector.tsx b/src/cli/components/ToolsetSelector.tsx index d788374..84f51aa 100644 --- a/src/cli/components/ToolsetSelector.tsx +++ b/src/cli/components/ToolsetSelector.tsx @@ -139,7 +139,13 @@ export function ToolsetSelector({ const totalItems = visibleToolsets.length + (hasShowAllOption ? 1 : 0); - useInput((_input, key) => { + useInput((input, key) => { + // CTRL-C: immediately cancel + if (key.ctrl && input === "c") { + onCancel(); + return; + } + if (key.upArrow) { setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) {