diff --git a/CLAUDE.md b/CLAUDE.md index 45a6262..ed5d84a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,7 +252,32 @@ Enable detailed authentication logging by setting: ```lua require("claudecode").setup({ - log_level = "debug" -- Shows auth token generation, validation, and failures + log_level = "debug", -- Shows auth token generation, validation, and failures + diff_opts = { + keep_terminal_focus = true, -- If true, moves focus back to terminal after diff opens + }, +}) +``` + +### Configuration Options + +#### Diff Options + +The `diff_opts` configuration allows you to customize diff behavior: + +- `keep_terminal_focus` (boolean, default: `false`) - When enabled, keeps focus in the Claude Code terminal when a diff opens instead of moving focus to the diff buffer. This allows you to continue using terminal keybindings like `` for accepting/rejecting diffs without accidentally triggering other mappings. + +**Example use case**: If you frequently use `` or arrow keys in the Claude Code terminal to accept/reject diffs, enable this option to prevent focus from moving to the diff buffer where `` might trigger unintended actions. + +```lua +require("claudecode").setup({ + diff_opts = { + keep_terminal_focus = true, -- If true, moves focus back to terminal after diff opens + auto_close_on_accept = true, + show_diff_stats = true, + vertical_split = true, + open_in_current_tab = true, + }, }) ``` diff --git a/README.md b/README.md index add7076..6c0cdcb 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,7 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). auto_close_on_accept = true, vertical_split = true, open_in_current_tab = true, + keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens }, }, keys = { diff --git a/dev-config.lua b/dev-config.lua index 2fd2cae..c1b0ca3 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -62,6 +62,7 @@ return { -- show_diff_stats = true, -- Show diff statistics -- vertical_split = true, -- Use vertical split for diffs -- open_in_current_tab = true, -- Open diffs in current tab vs new tab + -- keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens -- }, -- Terminal Configuration diff --git a/fixtures/nvim-tree/lazy-lock.json b/fixtures/nvim-tree/lazy-lock.json index 7786fdb..ebf5acd 100644 --- a/fixtures/nvim-tree/lazy-lock.json +++ b/fixtures/nvim-tree/lazy-lock.json @@ -1,6 +1,6 @@ { "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, - "nvim-tree.lua": { "branch": "master", "commit": "65bae449224b8a3bc149471b96587b23b13a9946" }, - "nvim-web-devicons": { "branch": "master", "commit": "4a8369f4c78ef6f6f895f0cec349e48f74330574" }, + "nvim-tree.lua": { "branch": "master", "commit": "0a7fcdf3f8ba208f4260988a198c77ec11748339" }, + "nvim-web-devicons": { "branch": "master", "commit": "3362099de3368aa620a8105b19ed04c2053e38c0" }, "tokyonight.nvim": { "branch": "main", "commit": "057ef5d260c1931f1dffd0f052c685dcd14100a3" } } diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 88bdee8..3206932 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -18,6 +18,7 @@ M.defaults = { show_diff_stats = true, vertical_split = true, open_in_current_tab = true, -- Use current tab instead of creating new tab + keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens }, models = { { name = "Claude Opus 4 (Latest)", value = "opus" }, @@ -78,6 +79,7 @@ function M.validate(config) assert(type(config.diff_opts.show_diff_stats) == "boolean", "diff_opts.show_diff_stats must be a boolean") assert(type(config.diff_opts.vertical_split) == "boolean", "diff_opts.vertical_split must be a boolean") assert(type(config.diff_opts.open_in_current_tab) == "boolean", "diff_opts.open_in_current_tab must be a boolean") + assert(type(config.diff_opts.keep_terminal_focus) == "boolean", "diff_opts.keep_terminal_focus must be a boolean") -- Validate env assert(type(config.env) == "table", "env must be a table") diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 631ac36..e23cf85 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -7,6 +7,7 @@ local logger = require("claudecode.logger") -- Global state management for active diffs local active_diffs = {} local autocmd_group +local config --- Get or create the autocmd group local function get_autocmd_group() @@ -41,13 +42,12 @@ local function find_main_editor_window() is_suitable = false end - -- Skip known sidebar filetypes and ClaudeCode terminal + -- Skip known sidebar filetypes if is_suitable and ( filetype == "neo-tree" or filetype == "neo-tree-popup" - or filetype == "ClaudeCode" or filetype == "NvimTree" or filetype == "oil" or filetype == "aerial" @@ -66,11 +66,39 @@ local function find_main_editor_window() return nil end +--- Find the Claude Code terminal window to keep focus there. +-- Uses the terminal provider to get the active terminal buffer, then finds its window. +-- @return number|nil Window ID of the Claude Code terminal window, or nil if not found +local function find_claudecode_terminal_window() + local terminal_ok, terminal_module = pcall(require, "claudecode.terminal") + if not terminal_ok then + return nil + end + + local terminal_bufnr = terminal_module.get_active_terminal_bufnr() + if not terminal_bufnr then + return nil + end + + -- Find the window containing this buffer + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(win) == terminal_bufnr then + local win_config = vim.api.nvim_win_get_config(win) + -- Skip floating windows + if not (win_config.relative and win_config.relative ~= "") then + return win + end + end + end + + return nil +end + --- Setup the diff module --- @param user_diff_config table|nil Reserved for future use -function M.setup(user_diff_config) - -- Currently no configuration needed for native diff - -- Parameter kept for API compatibility +-- @param user_config table|nil The configuration passed from init.lua +function M.setup(user_config) + -- Store the configuration for later use + config = user_config or {} end --- Open a diff view between two files @@ -513,7 +541,7 @@ function M._create_diff_view_from_window(target_window, old_file_path, new_buffe local buftype = vim.api.nvim_buf_get_option(buf, "buftype") local filetype = vim.api.nvim_buf_get_option(buf, "filetype") - if buftype == "terminal" or buftype == "prompt" or filetype == "neo-tree" or filetype == "ClaudeCode" then + if buftype == "terminal" or buftype == "prompt" or filetype == "neo-tree" then vim.cmd("vsplit") end @@ -576,6 +604,8 @@ function M._create_diff_view_from_window(target_window, old_file_path, new_buffe vim.cmd("diffthis") vim.cmd("wincmd =") + + -- Always focus the diff window first for proper visual flow and window arrangement vim.api.nvim_set_current_win(new_win) -- Store diff context in buffer variables for user commands @@ -583,6 +613,16 @@ function M._create_diff_view_from_window(target_window, old_file_path, new_buffe vim.b[new_buffer].claudecode_diff_new_win = new_win vim.b[new_buffer].claudecode_diff_target_win = target_window + -- After all diff setup is complete, optionally return focus to terminal + if config and config.diff_opts and config.diff_opts.keep_terminal_focus then + vim.schedule(function() + local terminal_win = find_claudecode_terminal_window() + if terminal_win then + vim.api.nvim_set_current_win(terminal_win) + end + end, 0) + end + -- Return window information for later storage return { new_window = new_win, diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index d69abc9..4f45409 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -43,7 +43,7 @@ M.version = { --- @field connection_wait_delay number Milliseconds to wait after connection before sending queued @ mentions. --- @field connection_timeout number Maximum time to wait for Claude Code to connect (milliseconds). --- @field queue_timeout number Maximum time to keep @ mentions in queue (milliseconds). ---- @field diff_opts { auto_close_on_accept: boolean, show_diff_stats: boolean, vertical_split: boolean, open_in_current_tab: boolean } Options for the diff provider. +--- @field diff_opts { auto_close_on_accept: boolean, show_diff_stats: boolean, vertical_split: boolean, open_in_current_tab: boolean, keep_terminal_focus: boolean } Options for the diff provider. --- @type ClaudeCode.Config local default_config = { @@ -62,6 +62,7 @@ local default_config = { show_diff_stats = true, vertical_split = true, open_in_current_tab = false, + keep_terminal_focus = false, }, } diff --git a/lua/claudecode/tools/open_file.lua b/lua/claudecode/tools/open_file.lua index 81c9ce8..639593d 100644 --- a/lua/claudecode/tools/open_file.lua +++ b/lua/claudecode/tools/open_file.lua @@ -88,7 +88,6 @@ local function find_main_editor_window() and ( filetype == "neo-tree" or filetype == "neo-tree-popup" - or filetype == "ClaudeCode" or filetype == "NvimTree" or filetype == "oil" or filetype == "aerial" diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 82f801c..3e0d63c 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -23,6 +23,9 @@ describe("Configuration", function() expect(config.defaults).to_have_key("log_level") expect(config.defaults).to_have_key("track_selection") expect(config.defaults).to_have_key("models") + expect(config.defaults).to_have_key("diff_opts") + expect(config.defaults.diff_opts).to_have_key("keep_terminal_focus") + expect(config.defaults.diff_opts.keep_terminal_focus).to_be_false() end) it("should apply and validate user configuration", function() @@ -136,5 +139,62 @@ describe("Configuration", function() expect(merged_config.models).to_be_table() end) + it("should accept valid keep_terminal_focus configuration", function() + local user_config = { + port_range = { min = 10000, max = 65535 }, + auto_start = true, + log_level = "info", + track_selection = true, + visual_demotion_delay_ms = 50, + connection_wait_delay = 200, + connection_timeout = 10000, + queue_timeout = 5000, + diff_opts = { + auto_close_on_accept = true, + show_diff_stats = true, + vertical_split = true, + open_in_current_tab = true, + keep_terminal_focus = true, + }, + env = {}, + models = { + { name = "Test Model", value = "test" }, + }, + } + + local final_config = config.apply(user_config) + expect(final_config.diff_opts.keep_terminal_focus).to_be_true() + end) + + it("should reject invalid keep_terminal_focus configuration", function() + local invalid_config = { + port_range = { min = 10000, max = 65535 }, + auto_start = true, + log_level = "info", + track_selection = true, + visual_demotion_delay_ms = 50, + connection_wait_delay = 200, + connection_timeout = 10000, + queue_timeout = 5000, + diff_opts = { + auto_close_on_accept = true, + show_diff_stats = true, + vertical_split = true, + open_in_current_tab = true, + keep_terminal_focus = "invalid", -- Should be boolean + }, + env = {}, + models = { + { name = "Test Model", value = "test" }, + }, + } + + local success, _ = pcall(function() + config.validate(invalid_config) + end) + + expect(success).to_be_false() + end) + teardown() end) diff --git a/tests/unit/diff_spec.lua b/tests/unit/diff_spec.lua index a7d0dda..5b690c8 100644 --- a/tests/unit/diff_spec.lua +++ b/tests/unit/diff_spec.lua @@ -49,6 +49,31 @@ describe("Diff Module", function() teardown() end) + describe("Configuration", function() + it("should store configuration in setup", function() + local test_config = { + diff_opts = { + keep_terminal_focus = true, + }, + } + + diff.setup(test_config) + + -- We can't directly test the stored config since it's local to the module, + -- but we can test that setup doesn't error and the module is properly initialized + expect(type(diff.setup)).to_be("function") + expect(type(diff.open_diff)).to_be("function") + end) + + it("should handle empty configuration", function() + -- This should not error + diff.setup(nil) + diff.setup({}) + + expect(type(diff.setup)).to_be("function") + end) + end) + describe("Temporary File Management (via Native Diff)", function() it("should create temporary files with correct content through native diff", function() local test_content = "This is test content\nLine 2\nLine 3"