Skip to content

feat: add ClaudeCodeFocus command for smart toggle behavior #40

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 1 commit into from
Jun 13, 2025
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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Using [lazy.nvim](https://github.com/folke/lazy.nvim):
keys = {
{ "<leader>a", nil, desc = "AI/Claude Code" },
{ "<leader>ac", "<cmd>ClaudeCode<cr>", desc = "Toggle Claude" },
{ "<leader>af", "<cmd>ClaudeCodeFocus<cr>", desc = "Focus Claude" },
{ "<leader>ar", "<cmd>ClaudeCode --resume<cr>", desc = "Resume Claude" },
{ "<leader>aC", "<cmd>ClaudeCode --continue<cr>", desc = "Continue Claude" },
{ "<leader>as", "<cmd>ClaudeCodeSend<cr>", mode = "v", desc = "Send to Claude" },
Expand Down Expand Up @@ -80,13 +81,19 @@ That's it! For more configuration options, see [Advanced Setup](#advanced-setup)

## Commands

- `:ClaudeCode [arguments]` - Toggle the Claude Code terminal window (arguments are passed to claude command)
- `:ClaudeCode [arguments]` - Toggle the Claude Code terminal window (simple show/hide behavior)
- `:ClaudeCodeFocus [arguments]` - Smart focus/toggle Claude terminal (switches to terminal if not focused, hides if focused)
- `:ClaudeCode --resume` - Resume a previous Claude conversation
- `:ClaudeCode --continue` - Continue Claude conversation
- `:ClaudeCodeSend` - Send current visual selection to Claude, or add files from tree explorer
- `:ClaudeCodeTreeAdd` - Add selected file(s) from tree explorer to Claude context (also available via ClaudeCodeSend)
- `:ClaudeCodeAdd <file-path> [start-line] [end-line]` - Add a specific file or directory to Claude context by path with optional line range

### Toggle Behavior

- **`:ClaudeCode`** - Simple toggle: Always show/hide terminal regardless of current focus
- **`:ClaudeCodeFocus`** - Smart focus: Focus terminal if not active, hide if currently focused

### Tree Integration

The `<leader>as` keybinding has context-aware behavior:
Expand Down Expand Up @@ -213,6 +220,7 @@ See [DEVELOPMENT.md](./DEVELOPMENT.md) for build instructions and development gu
keys = {
{ "<leader>a", nil, desc = "AI/Claude Code" },
{ "<leader>ac", "<cmd>ClaudeCode<cr>", desc = "Toggle Claude" },
{ "<leader>af", "<cmd>ClaudeCodeFocus<cr>", desc = "Focus Claude" },
{ "<leader>as", "<cmd>ClaudeCodeSend<cr>", mode = "v", desc = "Send to Claude" },
{
"<leader>as",
Expand Down
1 change: 1 addition & 0 deletions dev-config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ return {

-- Core Claude commands
{ "<leader>ac", "<cmd>ClaudeCode<cr>", desc = "Toggle Claude" },
{ "<leader>af", "<cmd>ClaudeCodeFocus<cr>", desc = "Focus Claude" },
{ "<leader>ar", "<cmd>ClaudeCode --resume<cr>", desc = "Resume Claude" },
{ "<leader>aC", "<cmd>ClaudeCode --continue<cr>", desc = "Continue Claude" },

Expand Down
16 changes: 14 additions & 2 deletions lua/claudecode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -664,10 +664,22 @@ function M._create_commands()
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<Esc>", true, false, true), "n", false)
end
local cmd_args = opts.args and opts.args ~= "" and opts.args or nil
terminal.toggle({}, cmd_args)
terminal.simple_toggle({}, cmd_args)
end, {
nargs = "*",
desc = "Toggle the Claude Code terminal window with optional arguments",
desc = "Toggle the Claude Code terminal window (simple show/hide) with optional arguments",
})

vim.api.nvim_create_user_command("ClaudeCodeFocus", function(opts)
local current_mode = vim.fn.mode()
if current_mode == "v" or current_mode == "V" or current_mode == "\22" then
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<Esc>", true, false, true), "n", false)
end
local cmd_args = opts.args and opts.args ~= "" and opts.args or nil
terminal.focus_toggle({}, cmd_args)
end, {
nargs = "*",
desc = "Smart focus/toggle Claude Code terminal (switches to terminal if not focused, hides if focused)",
})

vim.api.nvim_create_user_command("ClaudeCodeOpen", function(opts)
Expand Down
24 changes: 21 additions & 3 deletions lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,32 @@ function M.close()
get_provider().close()
end

--- Toggles the Claude terminal open or closed.
--- Simple toggle: always show/hide the Claude terminal regardless of focus.
-- @param opts_override table (optional) Overrides for terminal appearance (split_side, split_width_percentage).
-- @param cmd_args string|nil (optional) Arguments to append to the claude command.
function M.toggle(opts_override, cmd_args)
function M.simple_toggle(opts_override, cmd_args)
local effective_config = build_config(opts_override)
local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args)

get_provider().toggle(cmd_string, claude_env_table, effective_config)
get_provider().simple_toggle(cmd_string, claude_env_table, effective_config)
end

--- Smart focus toggle: switches to terminal if not focused, hides if currently focused.
-- @param opts_override table (optional) Overrides for terminal appearance (split_side, split_width_percentage).
-- @param cmd_args string|nil (optional) Arguments to append to the claude command.
function M.focus_toggle(opts_override, cmd_args)
local effective_config = build_config(opts_override)
local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args)

get_provider().focus_toggle(cmd_string, claude_env_table, effective_config)
end

--- Toggles the Claude terminal open or closed (legacy function - use simple_toggle or focus_toggle).
-- @param opts_override table (optional) Overrides for terminal appearance (split_side, split_width_percentage).
-- @param cmd_args string|nil (optional) Arguments to append to the claude command.
function M.toggle(opts_override, cmd_args)
-- Default to simple toggle for backward compatibility
M.simple_toggle(opts_override, cmd_args)
end

--- Gets the buffer number of the currently active Claude Code terminal.
Expand Down
53 changes: 51 additions & 2 deletions lua/claudecode/terminal/native.lua
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,51 @@ function M.close()
close_terminal()
end

--- Simple toggle: always show/hide terminal regardless of focus
--- @param cmd_string string
--- @param env_table table
--- @param effective_config table
function M.toggle(cmd_string, env_table, effective_config)
function M.simple_toggle(cmd_string, env_table, effective_config)
-- Check if we have a valid terminal buffer (process running)
local has_buffer = bufnr and vim.api.nvim_buf_is_valid(bufnr)
local is_visible = has_buffer and is_terminal_visible()

if is_visible then
-- Terminal is visible, hide it (but keep process running)
hide_terminal()
else
-- Terminal is not visible
if has_buffer then
-- Terminal process exists but is hidden, show it
if show_hidden_terminal(effective_config) then
logger.debug("terminal", "Showing hidden terminal")
else
logger.error("terminal", "Failed to show hidden terminal")
end
else
-- No terminal process exists, check if there's an existing one we lost track of
local existing_buf, existing_win = find_existing_claude_terminal()
if existing_buf and existing_win then
-- Recover the existing terminal
bufnr = existing_buf
winid = existing_win
logger.debug("terminal", "Recovered existing Claude terminal")
focus_terminal()
else
-- No existing terminal found, create a new one
if not open_terminal(cmd_string, env_table, effective_config) then
vim.notify("Failed to open Claude terminal using native fallback (simple_toggle).", vim.log.levels.ERROR)
end
end
end
end
end

--- Smart focus toggle: switches to terminal if not focused, hides if currently focused
--- @param cmd_string string
--- @param env_table table
--- @param effective_config table
function M.focus_toggle(cmd_string, env_table, effective_config)
-- Check if we have a valid terminal buffer (process running)
local has_buffer = bufnr and vim.api.nvim_buf_is_valid(bufnr)
local is_visible = has_buffer and is_terminal_visible()
Expand Down Expand Up @@ -325,12 +366,20 @@ function M.toggle(cmd_string, env_table, effective_config)
else
-- No existing terminal found, create a new one
if not open_terminal(cmd_string, env_table, effective_config) then
vim.notify("Failed to open Claude terminal using native fallback (toggle).", vim.log.levels.ERROR)
vim.notify("Failed to open Claude terminal using native fallback (focus_toggle).", vim.log.levels.ERROR)
end
end
end
end

--- Legacy toggle function for backward compatibility (defaults to simple_toggle)
--- @param cmd_string string
--- @param env_table table
--- @param effective_config table
function M.toggle(cmd_string, env_table, effective_config)
M.simple_toggle(cmd_string, env_table, effective_config)
end

--- @return number|nil
function M.get_active_bufnr()
if is_valid() then
Expand Down
45 changes: 42 additions & 3 deletions lua/claudecode/terminal/snacks.lua
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,39 @@ function M.close()
end
end

--- Simple toggle: always show/hide terminal regardless of focus
--- @param cmd_string string
--- @param env_table table
--- @param config table
function M.toggle(cmd_string, env_table, config)
function M.simple_toggle(cmd_string, env_table, config)
if not is_available() then
vim.notify("Snacks.nvim terminal provider selected but Snacks.terminal not available.", vim.log.levels.ERROR)
return
end

local logger = require("claudecode.logger")

-- Check if terminal exists and is visible
if terminal and terminal:buf_valid() and terminal.win then
-- Terminal is visible, hide it
logger.debug("terminal", "Simple toggle: hiding visible terminal")
terminal:toggle()
elseif terminal and terminal:buf_valid() and not terminal.win then
-- Terminal exists but not visible, show it
logger.debug("terminal", "Simple toggle: showing hidden terminal")
terminal:toggle()
else
-- No terminal exists, create new one
logger.debug("terminal", "Simple toggle: creating new terminal")
M.open(cmd_string, env_table, config)
end
end

--- Smart focus toggle: switches to terminal if not focused, hides if currently focused
--- @param cmd_string string
--- @param env_table table
--- @param config table
function M.focus_toggle(cmd_string, env_table, config)
if not is_available() then
vim.notify("Snacks.nvim terminal provider selected but Snacks.terminal not available.", vim.log.levels.ERROR)
return
Expand All @@ -137,7 +166,7 @@ function M.toggle(cmd_string, env_table, config)

-- Terminal exists, is valid, but not visible
if terminal and terminal:buf_valid() and not terminal.win then
logger.debug("terminal", "Toggle existing managed Snacks terminal")
logger.debug("terminal", "Focus toggle: showing hidden terminal")
terminal:toggle()
-- Terminal exists, is valid, and is visible
elseif terminal and terminal:buf_valid() and terminal.win then
Expand All @@ -146,9 +175,11 @@ function M.toggle(cmd_string, env_table, config)

-- you're IN it
if claude_term_neovim_win_id == current_neovim_win_id then
logger.debug("terminal", "Focus toggle: hiding terminal (currently focused)")
terminal:toggle()
-- you're NOT in it
else
logger.debug("terminal", "Focus toggle: focusing terminal")
vim.api.nvim_set_current_win(claude_term_neovim_win_id)
if terminal.buf and vim.api.nvim_buf_is_valid(terminal.buf) then
if vim.api.nvim_buf_get_option(terminal.buf, "buftype") == "terminal" then
Expand All @@ -160,11 +191,19 @@ function M.toggle(cmd_string, env_table, config)
end
-- No terminal exists
else
logger.debug("terminal", "No valid terminal exists, creating new one")
logger.debug("terminal", "Focus toggle: creating new terminal")
M.open(cmd_string, env_table, config)
end
end

--- Legacy toggle function for backward compatibility (defaults to simple_toggle)
--- @param cmd_string string
--- @param env_table table
--- @param config table
function M.toggle(cmd_string, env_table, config)
M.simple_toggle(cmd_string, env_table, config)
end

--- @return number|nil
function M.get_active_bufnr()
if terminal and terminal:buf_valid() and terminal.buf then
Expand Down
18 changes: 10 additions & 8 deletions tests/unit/init_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ describe("claudecode.init", function()
before_each(function()
mock_terminal = {
toggle = spy.new(function() end),
simple_toggle = spy.new(function() end),
focus_toggle = spy.new(function() end),
open = spy.new(function() end),
close = spy.new(function() end),
setup = spy.new(function() end),
Expand Down Expand Up @@ -369,8 +371,8 @@ describe("claudecode.init", function()

command_handler({ args = "--resume --verbose" })

assert(#mock_terminal.toggle.calls > 0, "terminal.toggle was not called")
local call_args = mock_terminal.toggle.calls[1].vals
assert(#mock_terminal.simple_toggle.calls > 0, "terminal.simple_toggle was not called")
local call_args = mock_terminal.simple_toggle.calls[1].vals
assert.is_table(call_args[1], "First argument should be a table")
assert.is_equal("--resume --verbose", call_args[2], "Second argument should be the command args")
end)
Expand Down Expand Up @@ -412,8 +414,8 @@ describe("claudecode.init", function()

command_handler({ args = "" })

assert(#mock_terminal.toggle.calls > 0, "terminal.toggle was not called")
local call_args = mock_terminal.toggle.calls[1].vals
assert(#mock_terminal.simple_toggle.calls > 0, "terminal.simple_toggle was not called")
local call_args = mock_terminal.simple_toggle.calls[1].vals
assert.is_nil(call_args[2], "Second argument should be nil for empty args")
end)

Expand All @@ -431,8 +433,8 @@ describe("claudecode.init", function()

command_handler({ args = nil })

assert(#mock_terminal.toggle.calls > 0, "terminal.toggle was not called")
local call_args = mock_terminal.toggle.calls[1].vals
assert(#mock_terminal.simple_toggle.calls > 0, "terminal.simple_toggle was not called")
local call_args = mock_terminal.simple_toggle.calls[1].vals
assert.is_nil(call_args[2], "Second argument should be nil when args is nil")
end)

Expand All @@ -450,8 +452,8 @@ describe("claudecode.init", function()

command_handler({})

assert(#mock_terminal.toggle.calls > 0, "terminal.toggle was not called")
local call_args = mock_terminal.toggle.calls[1].vals
assert(#mock_terminal.simple_toggle.calls > 0, "terminal.simple_toggle was not called")
local call_args = mock_terminal.simple_toggle.calls[1].vals
assert.is_nil(call_args[2], "Second argument should be nil when no args provided")
end)
end)
Expand Down
Loading