Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4345,9 +4345,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const { trigger } = resolveActiveComposerTrigger();
const menuIsActive = composerMenuOpenRef.current || trigger !== null;
if (key === "Escape") {
if (!menuIsActive || event.isComposing || event.keyCode === 229) return false;
dismissComposerTrigger(trigger);
composerMenuOpenRef.current = false;
if (event.isComposing || event.keyCode === 229) return false;
// Like Enter, an open menu takes Escape first; otherwise Escape to stop can stop the turn.
if (menuIsActive) {
dismissComposerTrigger(trigger);
composerMenuOpenRef.current = false;
return true;
}
if (phase !== "running" || !settings.escapeToStop) return false;
if (!event.repeat) onInterrupt();
Comment on lines +4355 to +4356

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Modified Escape also stops turns

When Escape to stop is enabled and a turn is running, pressing Ctrl+Escape in the focused composer can stop the turn if no keybinding consumes it first. The handler checks for Escape but not for modifier keys, so a shortcut other than bare Escape can unexpectedly interrupt the agent.

Suggested change
if (phase !== "running" || !settings.escapeToStop) return false;
if (!event.repeat) onInterrupt();
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false;
if (phase !== "running" || !settings.escapeToStop) return false;
if (!event.repeat) onInterrupt();

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex

return true;
}
if (menuIsActive && (submissionIntent === null || submissionIntent === "foreground")) {
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/settings/KeybindingsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { formatShortcutLabel } from "../../keybindings";
import { cn } from "../../lib/utils";
import { serverEnvironment } from "../../state/server";
import { useSettingsScope } from "./SettingsScopeContext";
import { useScopedSettings } from "./useScopedSettings";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
Expand Down Expand Up @@ -303,6 +304,13 @@ function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray<string> }
);
}

/** Flags a bare Escape binding while the Escape to stop setting is on. */
export function EscapeToStopConflictWarning({ labels }: { labels: ReadonlyArray<string> }) {
if (labels.length === 0) return null;
const description = `Conflicts with ${labels.join(", ")}.`;
return <WarningTooltipIcon label={description}>{description}</WarningTooltipIcon>;
}

function WhenVariableSelect({
value,
variables,
Expand Down Expand Up @@ -1021,6 +1029,7 @@ function KeybindingHoverRowMenu(props: {
function KeybindingSettingsRow(props: KeybindingRowProps) {
const { row, isSaving, anchorId, allRows, variables, onSave, onReset, onRemove } = props;
const editor = useKeybindingRowEditor({ row, allRows, onSave });
const escapeToStop = useScopedSettings((settings) => settings.escapeToStop);

return (
<SettingsRow
Expand All @@ -1031,6 +1040,9 @@ function KeybindingSettingsRow(props: KeybindingRowProps) {
control={
<div className="flex flex-wrap items-center justify-end gap-1.5">
<KeybindingConflictWarning labels={editor.conflictLabels} />
<EscapeToStopConflictWarning
labels={escapeToStop && editor.keyDraft === "esc" ? ["Escape to stop"] : []}
/>
<KeybindingHoverRowMenu
row={row}
isSaving={isSaving}
Expand Down Expand Up @@ -1229,6 +1241,7 @@ function NewKeybindingCancelIcon({
function NewKeybindingSettingsRow(props: NewKeybindingProps) {
const { commandOptions, allRows, variables, isSaving, onSave, onCancel } = props;
const draft = useNewKeybindingDraft({ allRows, onSave });
const escapeToStop = useScopedSettings((settings) => settings.escapeToStop);

return (
<SettingsRow
Expand All @@ -1248,6 +1261,9 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) {
className="w-56"
/>
<KeybindingConflictWarning labels={draft.conflictLabels} />
<EscapeToStopConflictWarning
labels={escapeToStop && draft.keyDraft === "esc" ? ["Escape to stop"] : []}
/>
<NewKeybindingKeyInput draft={draft} className="w-44" />
<Button size="sm" disabled={isSaving || !draft.canSave} onClick={draft.save}>
{isSaving ? "Saving" : "Save"}
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ import {
} from "./useScopedSettings";
import { useScopedModelDisabledReason } from "./useScopedModelAvailability";
import { useSettingsScope } from "./SettingsScopeContext";
import { EscapeToStopConflictWarning } from "./KeybindingsSettings";
import { commandLabel, shortcutToKeybindingInput } from "./KeybindingsSettings.logic";
import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
import { useThreadActions } from "../../hooks/useThreadActions";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
Expand Down Expand Up @@ -607,6 +609,9 @@ export function useSettingsRestore(onRestored?: () => void) {
? ["Rich text composer"]
: []),
...(settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ["Send shortcut"] : []),
...(settings.escapeToStop !== DEFAULT_UNIFIED_SETTINGS.escapeToStop
? ["Escape to stop"]
: []),
...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior
? ["Follow-up behavior"]
: []),
Expand Down Expand Up @@ -673,6 +678,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.composerCollapseOnScroll,
settings.composerRichTextEnabled,
settings.sendShortcut,
settings.escapeToStop,
settings.followUpBehavior,
settings.addProjectBaseDirectory,
settings.defaultThreadEnvMode,
Expand Down Expand Up @@ -795,6 +801,7 @@ export function useSettingsRestore(onRestored?: () => void) {
composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled,
sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut,
escapeToStop: DEFAULT_UNIFIED_SETTINGS.escapeToStop,
followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior,
contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
Expand Down Expand Up @@ -2175,6 +2182,16 @@ export function GeneralSettingsPanel() {
// needs exactly one environment.
const environmentId = environment?.environmentId ?? null;
const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null;
// Keybindings run before the composer, so a bare Escape binding can take Escape from Escape to stop.
const escapeBindingLabels = settings.escapeToStop
? [
...new Set(
(environment?.serverConfig?.keybindings ?? [])
.filter((binding) => shortcutToKeybindingInput(binding.shortcut) === "esc")
.map((binding) => commandLabel(binding.command)),
),
]
: [];
const hasServerTargets = connectedEnvironments.length > 0;
const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);
const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]);
Expand Down Expand Up @@ -2824,6 +2841,31 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
{...searchableSetting("escape-to-stop")}
description="Press Escape in the composer to stop the running agent. An open suggestion menu closes first."
resetAction={
settings.escapeToStop !== DEFAULT_UNIFIED_SETTINGS.escapeToStop ? (
<SettingResetButton
label="escape to stop"
onClick={() =>
updateSettings({ escapeToStop: DEFAULT_UNIFIED_SETTINGS.escapeToStop })
}
/>
) : null
}
control={
<>
<EscapeToStopConflictWarning labels={escapeBindingLabels} />
<Switch
checked={settings.escapeToStop}
onCheckedChange={(checked) => updateSettings({ escapeToStop: Boolean(checked) })}
aria-label="Escape to stop"
/>
</>
}
/>

<SettingsRow
{...searchableSetting("follow-up-behavior")}
description={
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["enter return command ctrl multiline prompt new line composer"],
},
{
id: "escape-to-stop",
title: "Escape to stop",
to: "/settings/general",
searchTerms: ["esc escape stop cancel interrupt running turn agent composer"],
},
{
id: "follow-up-behavior",
title: "Follow-up behavior",
Expand Down
3 changes: 3 additions & 0 deletions docs/user/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ send button to use the configured follow-up behavior.
When an active turn has queued messages, `mod+Shift+Enter` sends the first as a
steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings.

To stop the agent from the composer with Escape, turn on **Settings → General →
Escape to stop**. If a suggestion menu is open, Escape closes the menu first.

Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host.
Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the
workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ export const ClientSettingsSchema = Schema.Struct({
sendShortcut: Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"]).pipe(
Schema.withDecodingDefault(Effect.succeed("enter")),
),
escapeToStop: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
followUpBehavior: Schema.Literals(["queue", "steer"]).pipe(
Schema.withDecodingDefault(Effect.succeed("queue")),
),
Expand Down Expand Up @@ -1771,6 +1772,7 @@ export const ClientSettingsPatch = Schema.Struct({
composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),
composerRichTextEnabled: Schema.optionalKey(Schema.Boolean),
sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])),
escapeToStop: Schema.optionalKey(Schema.Boolean),
followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])),
proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
Expand Down
Loading