fix: add consistent CTRL-C and ESC handling to all dialogs (#426)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-29 21:19:57 -08:00
committed by GitHub
parent f5a1a5e400
commit 397eb5e390
19 changed files with 302 additions and 171 deletions

View File

@@ -5802,6 +5802,7 @@ Plan file path: ${planFilePath}`;
<QuestionDialog
questions={getQuestionsFromApproval(currentApproval)}
onSubmit={handleQuestionSubmit}
onCancel={handleCancelApprovals}
/>
)}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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();
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -18,200 +18,219 @@ interface Question {
type Props = {
questions: Question[];
onSubmit: (answers: Record<string, string>) => void;
onCancel?: () => void;
};
export const QuestionDialog = memo(({ questions, onSubmit }: Props) => {
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [selectedOption, setSelectedOption] = useState(0);
const [isOtherMode, setIsOtherMode] = useState(false);
const [otherText, setOtherText] = useState("");
const [selectedMulti, setSelectedMulti] = useState<Set<number>>(new Set());
export const QuestionDialog = memo(
({ questions, onSubmit, onCancel }: Props) => {
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [selectedOption, setSelectedOption] = useState(0);
const [isOtherMode, setIsOtherMode] = useState(false);
const [otherText, setOtherText] = useState("");
const [selectedMulti, setSelectedMulti] = useState<Set<number>>(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 (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color={colors.approval.header}>
<Text bold>[{currentQuestion.header}]</Text>{" "}
{currentQuestion.question}
</Text>
</Box>
{questions.length > 1 && (
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text dimColor>
Question {currentQuestionIndex + 1} of {questions.length}
<Text color={colors.approval.header}>
<Text bold>[{currentQuestion.header}]</Text>{" "}
{currentQuestion.question}
</Text>
</Box>
)}
{isOtherMode ? (
<Box flexDirection="column">
<Text dimColor>Type your response (Esc to cancel):</Text>
<Box marginTop={1}>
<Text color={colors.approval.header}>&gt; </Text>
<PasteAwareTextInput
value={otherText}
onChange={setOtherText}
onSubmit={handleOtherSubmit}
/>
</Box>
</Box>
) : (
<Box flexDirection="column">
{optionsWithOther.map((option, index) => {
const isSelected = index === selectedOption;
const isChecked = selectedMulti.has(index);
const color = isSelected ? colors.approval.header : undefined;
return (
<Box
key={option.label}
flexDirection="column"
marginBottom={index < optionsWithOther.length - 1 ? 1 : 0}
>
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<Text color={color}>{isSelected ? ">" : " "}</Text>
</Box>
{currentQuestion.multiSelect &&
index < optionsWithOther.length - 1 && (
<Box width={4} flexShrink={0}>
<Text color={color}>[{isChecked ? "x" : " "}]</Text>
</Box>
)}
<Box flexGrow={1}>
<Text color={color} bold={isSelected}>
{index + 1}. {option.label}
</Text>
</Box>
</Box>
{option.description && (
<Box paddingLeft={currentQuestion.multiSelect ? 6 : 2}>
<Text dimColor>{option.description}</Text>
</Box>
)}
</Box>
);
})}
<Box marginTop={1}>
{questions.length > 1 && (
<Box marginBottom={1}>
<Text dimColor>
{currentQuestion.multiSelect
? "Space to toggle, Enter to confirm selection"
: `Enter to select, or type 1-${optionsWithOther.length}`}
Question {currentQuestionIndex + 1} of {questions.length}
</Text>
</Box>
</Box>
)}
</Box>
);
});
)}
{isOtherMode ? (
<Box flexDirection="column">
<Text dimColor>Type your response (Esc to cancel):</Text>
<Box marginTop={1}>
<Text color={colors.approval.header}>&gt; </Text>
<PasteAwareTextInput
value={otherText}
onChange={setOtherText}
onSubmit={handleOtherSubmit}
/>
</Box>
</Box>
) : (
<Box flexDirection="column">
{optionsWithOther.map((option, index) => {
const isSelected = index === selectedOption;
const isChecked = selectedMulti.has(index);
const color = isSelected ? colors.approval.header : undefined;
return (
<Box
key={option.label}
flexDirection="column"
marginBottom={index < optionsWithOther.length - 1 ? 1 : 0}
>
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<Text color={color}>{isSelected ? ">" : " "}</Text>
</Box>
{currentQuestion.multiSelect &&
index < optionsWithOther.length - 1 && (
<Box width={4} flexShrink={0}>
<Text color={color}>[{isChecked ? "x" : " "}]</Text>
</Box>
)}
<Box flexGrow={1}>
<Text color={color} bold={isSelected}>
{index + 1}. {option.label}
</Text>
</Box>
</Box>
{option.description && (
<Box paddingLeft={currentQuestion.multiSelect ? 6 : 2}>
<Text dimColor>{option.description}</Text>
</Box>
)}
</Box>
);
})}
<Box marginTop={1}>
<Text dimColor>
{currentQuestion.multiSelect
? "Space to toggle, Enter to confirm selection"
: `Enter to select, or type 1-${optionsWithOther.length}`}
</Text>
</Box>
</Box>
)}
</Box>
);
},
);
QuestionDialog.displayName = "QuestionDialog";

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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) {

View File

@@ -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) {