diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bc7b8119bc..9425775663 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -402,7 +402,7 @@ export namespace Config { .default("ctrl+x") .describe("Leader key for keybind combinations"), app_help: z.string().optional().default("h").describe("Show help dialog"), - app_exit: z.string().optional().default("ctrl+c,q").describe("Exit the application"), + app_exit: z.string().optional().default("ctrl+d,ctrl+c,q").describe("Exit the application"), editor_open: z.string().optional().default("e").describe("Open external editor"), theme_list: z.string().optional().default("t").describe("List available themes"), project_init: z.string().optional().default("i").describe("Create/update AGENTS.md"), diff --git a/packages/sdk/python/src/opencode_ai/models/keybinds_config.py b/packages/sdk/python/src/opencode_ai/models/keybinds_config.py index 05053206da..15223574f5 100644 --- a/packages/sdk/python/src/opencode_ai/models/keybinds_config.py +++ b/packages/sdk/python/src/opencode_ai/models/keybinds_config.py @@ -15,7 +15,7 @@ class KeybindsConfig: Attributes: leader (Union[Unset, str]): Leader key for keybind combinations Default: 'ctrl+x'. app_help (Union[Unset, str]): Show help dialog Default: 'h'. - app_exit (Union[Unset, str]): Exit the application Default: 'ctrl+c,q'. + app_exit (Union[Unset, str]): Exit the application Default: 'ctrl+d,ctrl+c,q'. editor_open (Union[Unset, str]): Open external editor Default: 'e'. theme_list (Union[Unset, str]): List available themes Default: 't'. project_init (Union[Unset, str]): Create/update AGENTS.md Default: 'i'. @@ -67,7 +67,7 @@ class KeybindsConfig: leader: Union[Unset, str] = "ctrl+x" app_help: Union[Unset, str] = "h" - app_exit: Union[Unset, str] = "ctrl+c,q" + app_exit: Union[Unset, str] = "ctrl+d,ctrl+c,q" editor_open: Union[Unset, str] = "e" theme_list: Union[Unset, str] = "t" project_init: Union[Unset, str] = "i" diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index d552b78ece..149ee85352 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -367,7 +367,6 @@ func LoadFromConfig(config *opencode.Config, customCommands []opencode.Command) Description: "last message", Keybindings: parseBindings("ctrl+alt+g"), }, - { Name: MessagesCopyCommand, Description: "copy message", @@ -388,7 +387,8 @@ func LoadFromConfig(config *opencode.Config, customCommands []opencode.Command) { Name: AppExitCommand, Description: "exit the app", - Keybindings: parseBindings("ctrl+c", "q"), + // NOTE: ctrl+c requires a double press to exit while ctrl+d requires a single press + Keybindings: parseBindings("ctrl+c", "ctrl+d", "q"), Trigger: []string{"exit", "quit", "q"}, }, } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 3a0bc37300..fe3c816ada 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -305,9 +305,14 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // 8. Handle exit key debounce for app exit when using non-leader command + // 8. Handle immediate exit command both for empty and non-empty editor exitCommand := a.app.Commands[commands.AppExitCommand] - if exitCommand.Matches(msg, a.app.IsLeaderSequence) { + if exitCommand.Matches(msg, a.app.IsLeaderSequence) && keyString == "ctrl+d" && a.editor.Length() == 0 { + return a, util.CmdHandler(commands.ExecuteCommandMsg(exitCommand)) + } + + // 9. Handle exit key debounce for app exit when using non-leader command + if exitCommand.Matches(msg, a.app.IsLeaderSequence) && keyString != "ctrl+d" { switch a.exitKeyState { case ExitKeyIdle: // First exit key press - start debounce timer @@ -324,22 +329,26 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // 9. Check again for commands that don't require leader (excluding interrupt when busy and exit when in debounce) + // 10. Check again for commands that don't require leader (excluding interrupt when busy and exit when in debounce) matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence) - if len(matches) > 0 { + if len(matches) > 0 && keyString != "ctrl+d" { // Skip interrupt key if we're in debounce mode and app is busy if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle { return a, nil } + // Skip ctrl+d immediate exit key if the editor has content. Allow editor update commands to handle it as forward delete. + if exitCommand.Matches(msg, a.app.IsLeaderSequence) && keyString == "ctrl+d" && a.editor.Length() > 0 { + return a, nil + } return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches)) } - // Fallback: suspend if ctrl+z is pressed and no user keybind matched + // 11. Fallback: suspend if ctrl+z is pressed and no user keybind matched if keyString == "ctrl+z" { return a, tea.Suspend } - // 10. Fallback to editor. This is for other characters like backspace, tab, etc. + // 12. Fallback to editor. This is for other characters like backspace, tab, etc. updatedEditor, cmd := a.editor.Update(msg) a.editor = updatedEditor.(chat.EditorComponent) return a, cmd