Skip to content

feat: add keep_terminal_focus option for diff views #95

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
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
27 changes: 26 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<CR>` for accepting/rejecting diffs without accidentally triggering other mappings.

**Example use case**: If you frequently use `<CR>` 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 `<CR>` 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,
},
})
```

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions dev-config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions fixtures/nvim-tree/lazy-lock.json
Original file line number Diff line number Diff line change
@@ -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" }
}
2 changes: 2 additions & 0 deletions lua/claudecode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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")
Expand Down
54 changes: 47 additions & 7 deletions lua/claudecode/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -576,13 +604,25 @@ 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
vim.b[new_buffer].claudecode_diff_tab_name = tab_name
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,
Expand Down
3 changes: 2 additions & 1 deletion lua/claudecode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -62,6 +62,7 @@ local default_config = {
show_diff_stats = true,
vertical_split = true,
open_in_current_tab = false,
keep_terminal_focus = false,
},
}

Expand Down
1 change: 0 additions & 1 deletion lua/claudecode/tools/open_file.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
60 changes: 60 additions & 0 deletions tests/unit/config_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
25 changes: 25 additions & 0 deletions tests/unit/diff_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down