From 1410af50880a69f4a1bbdbbbcb41b7a78769ab11 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 16 Jun 2025 15:21:40 +0200 Subject: [PATCH 1/8] fix: remove automatic terminal opening from ClaudeCodeSend Foundation fix for #42 - removes automatic terminal opening behavior from ClaudeCodeSend command to prevent focus_terminal errors when terminal buffer is hidden. - Remove terminal.open() calls after successful ClaudeCodeSend operations - Update snacks terminal to check window validity before focus - Update tests to reflect new behavior - Update dev-config and documentation This establishes the foundation for the complete fix which will address the core focus_terminal issue in native terminal implementation. Change-Id: Ide3692617e5c6ce721782eab9cf8f2eeeeef3df5 Signed-off-by: Thomas Kosiewski --- STORY.md | 4 ++-- dev-config.lua | 3 +-- lua/claudecode/init.lua | 19 ++----------------- lua/claudecode/terminal/snacks.lua | 9 ++++++--- tests/unit/claudecode_send_command_spec.lua | 5 +++-- 5 files changed, 14 insertions(+), 26 deletions(-) diff --git a/STORY.md b/STORY.md index 7decf5b..47aa219 100644 --- a/STORY.md +++ b/STORY.md @@ -4,7 +4,7 @@ While browsing Reddit at DevOpsCon in London, I stumbled upon a post that caught my eye: someone mentioned finding .vsix files in Anthropic's npm package for their Claude Code VS Code extension. -Link to the Reddit post: https://www.reddit.com/r/ClaudeAI/comments/1klpzvl/hidden_jetbrains_vs_code_plugin_in_todays_release/ +Link to the Reddit post: My first thought? "No way, they wouldn't ship the source like that." @@ -45,7 +45,7 @@ What I discovered was fascinating: Armed with this knowledge, I faced a new challenge: I wanted this in Neovim, but I didn't know Lua. -So I did what any reasonable person would do in 2024 — I used AI to help me build it. Using Roo Code with Gemini 2.5 Pro, I scaffolded a Neovim plugin that implements the same protocol. +So I did what any reasonable person would do in 2025 — I used AI to help me build it. Using Roo Code with Gemini 2.5 Pro, I scaffolded a Neovim plugin that implements the same protocol. (Note: Claude 4 models were not publicly available at the time of writing the extension.) The irony isn't lost on me: I used AI to reverse-engineer an AI tool, then used AI to build a plugin for AI. diff --git a/dev-config.lua b/dev-config.lua index 4ac309f..4b73059 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -1,12 +1,11 @@ -- Development configuration for claudecode.nvim -- This is Thomas's personal config for developing claudecode.nvim -- Symlink this to your personal Neovim config: --- ln -s ~/GitHub/claudecode.nvim/dev-config.lua ~/.config/nvim/lua/plugins/dev-claudecode.lua +-- ln -s ~/projects/claudecode.nvim/dev-config.lua ~/.config/nvim/lua/plugins/dev-claudecode.lua return { "coder/claudecode.nvim", dev = true, -- Use local development version - dir = "~/GitHub/claudecode.nvim", -- Adjust path as needed keys = { -- AI/Claude Code prefix { "a", nil, desc = "AI/Claude Code" }, diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 32c1207..d24dc57 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -443,17 +443,13 @@ function M._create_commands() end local sent_successfully = selection_module.send_at_mention_for_visual_selection(line1, line2) if sent_successfully then - -- Exit any potential visual mode (for consistency) and focus Claude terminal + -- Exit any potential visual mode (for consistency) pcall(function() if vim.api and vim.api.nvim_feedkeys then local esc = vim.api.nvim_replace_termcodes("", true, false, true) vim.api.nvim_feedkeys(esc, "i", true) end end) - local terminal_ok, terminal = pcall(require, "claudecode.terminal") - if terminal_ok then - terminal.open({}) - end end else logger.error("command", "ClaudeCodeSend: Failed to load selection module.") @@ -481,24 +477,13 @@ function M._create_commands() local message = success_count == 1 and "Added 1 file to Claude context from visual selection" or string.format("Added %d files to Claude context from visual selection", success_count) logger.debug("command", message) - - local terminal_ok, terminal = pcall(require, "claudecode.terminal") - if terminal_ok then - terminal.open({}) - end end return end end local selection_module_ok, selection_module = pcall(require, "claudecode.selection") if selection_module_ok then - local sent_successfully = selection_module.send_at_mention_for_visual_selection() - if sent_successfully then - local terminal_ok, terminal = pcall(require, "claudecode.terminal") - if terminal_ok then - terminal.open({}) - end - end + selection_module.send_at_mention_for_visual_selection() end end diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index fda68ce..129062a 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -76,9 +76,12 @@ function M.open(cmd_string, env_table, config) terminal:focus() local term_buf_id = terminal.buf if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then - vim.api.nvim_win_call(terminal.win, function() - vim.cmd("startinsert") - end) + -- Check if window is valid before calling nvim_win_call + if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then + vim.api.nvim_win_call(terminal.win, function() + vim.cmd("startinsert") + end) + end end return end diff --git a/tests/unit/claudecode_send_command_spec.lua b/tests/unit/claudecode_send_command_spec.lua index 93a6188..61f6220 100644 --- a/tests/unit/claudecode_send_command_spec.lua +++ b/tests/unit/claudecode_send_command_spec.lua @@ -216,7 +216,7 @@ describe("ClaudeCodeSend Command Range Functionality", function() assert(mock_selection_module.last_call.line2 == nil) end) - it("should exit visual mode and focus terminal on successful send", function() + it("should exit visual mode on successful send", function() assert(command_callback ~= nil, "Command callback should be set") local opts = { @@ -228,7 +228,8 @@ describe("ClaudeCodeSend Command Range Functionality", function() command_callback(opts) assert.spy(_G.vim.api.nvim_feedkeys).was_called() - assert.spy(mock_terminal.open).was_called() + -- Terminal should not be automatically opened + assert.spy(mock_terminal.open).was_not_called() end) it("should handle server not running", function() From dd81f17f9759251af443e5339da5eb61fb661bb8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Jun 2025 13:49:06 +0200 Subject: [PATCH 2/8] feat: implement unified connection-aware @ mention system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a robust, centralized architecture for @ mention functionality that intelligently handles Claude Code connection states and provides seamless UX. Key Features: • Unified API: Single `send_at_mention()` function for all @ mention operations • Smart connection detection: Automatically detects if Claude Code is connected • Intelligent queueing: Queues @ mentions when offline, processes when connected • Automatic terminal management: Launches terminal when needed, shows when connected • Robust error handling: Proper timeouts, error propagation, and retry logic Architecture Improvements: • Centralized all @ mention logic from scattered command implementations • Applied DRY principles to eliminate code duplication • Added connection state management with configurable timeouts • Improved terminal visibility logic with connection awareness • Enhanced queue management for offline @ mentions All commands (ClaudeCodeSend, ClaudeCodeAdd, ClaudeCodeTreeAdd, selection module) now use the unified system, providing consistent behavior and better UX. Includes comprehensive test updates to support the new architecture. Change-Id: Ie4201162be96e066bbe9af4349228ef068f3a963 Signed-off-by: Thomas Kosiewski --- README.md | 163 +++++-- dev-config.lua | 37 +- lua/claudecode/config.lua | 15 + lua/claudecode/init.lua | 462 ++++++++++++++------ lua/claudecode/selection.lua | 37 +- lua/claudecode/server/init.lua | 8 + lua/claudecode/terminal.lua | 55 ++- tests/config_test.lua | 3 + tests/selection_test.lua | 33 +- tests/unit/at_mention_edge_cases_spec.lua | 12 +- tests/unit/claudecode_add_command_spec.lua | 17 + tests/unit/claudecode_send_command_spec.lua | 5 +- tests/unit/config_spec.lua | 3 + tests/unit/init_spec.lua | 2 + 14 files changed, 653 insertions(+), 199 deletions(-) diff --git a/README.md b/README.md index 97da123..26ac98b 100644 --- a/README.md +++ b/README.md @@ -214,10 +214,26 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). See [DEVELOPMENT.md](./DEVELOPMENT.md) for build instructions and development guidelines. Tests can be run with `make test`. -## Advanced Setup +## Configuration + +### Quick Setup + +For most users, the default configuration is sufficient: + +```lua +{ + "coder/claudecode.nvim", + dependencies = { + "folke/snacks.nvim", -- optional + }, + config = true, +} +``` + +### Advanced Configuration
-Full configuration with all options +Complete configuration options ```lua { @@ -226,65 +242,128 @@ See [DEVELOPMENT.md](./DEVELOPMENT.md) for build instructions and development gu "folke/snacks.nvim", -- Optional for enhanced terminal }, opts = { - -- Server options - port_range = { min = 10000, max = 65535 }, - auto_start = true, - log_level = "info", - - -- Terminal options + -- Server Configuration + port_range = { min = 10000, max = 65535 }, -- WebSocket server port range + auto_start = true, -- Auto-start server on Neovim startup + log_level = "info", -- "trace", "debug", "info", "warn", "error" + terminal_cmd = nil, -- Custom terminal command (default: "claude") + + -- Selection Tracking + track_selection = true, -- Enable real-time selection tracking + visual_demotion_delay_ms = 50, -- Delay before demoting visual selection (ms) + + -- Connection Management + connection_wait_delay = 200, -- Wait time after connection before sending queued @ mentions (ms) + connection_timeout = 10000, -- Max time to wait for Claude Code connection (ms) + queue_timeout = 3000, -- Max time to keep @ mentions in queue (ms) + + -- Terminal Configuration terminal = { - split_side = "right", - split_width_percentage = 0.3, - provider = "auto", -- "auto" (default), "snacks", or "native" - auto_close = true, -- Auto-close terminal after command completion + split_side = "right", -- "left" or "right" + split_width_percentage = 0.30, -- Width as percentage (0.0 to 1.0) + provider = "auto", -- "auto", "snacks", or "native" + show_native_term_exit_tip = true, -- Show exit tip for native terminal + auto_close = true, -- Auto-close terminal after command completion }, - -- Diff options + -- Diff Integration diff_opts = { - auto_close_on_accept = true, - vertical_split = true, + auto_close_on_accept = true, -- Close diff view after accepting changes + 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 }, }, config = true, - keys = { - { "a", nil, desc = "AI/Claude Code" }, - { "ac", "ClaudeCode", desc = "Toggle Claude" }, - { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, - { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, - { - "as", - "ClaudeCodeTreeAdd", - desc = "Add file", - ft = { "NvimTree", "neo-tree" }, - }, - { "ao", "ClaudeCodeOpen", desc = "Open Claude" }, - { "ax", "ClaudeCodeClose", desc = "Close Claude" }, - }, } ```
-### Terminal Auto-Close Behavior +### Configuration Options Explained + +#### Server Options + +- **`port_range`**: Port range for the WebSocket server that Claude connects to +- **`auto_start`**: Whether to automatically start the integration when Neovim starts +- **`terminal_cmd`**: Override the default "claude" command (useful for custom Claude installations) +- **`log_level`**: Controls verbosity of plugin logs + +#### Selection Tracking + +- **`track_selection`**: Enables real-time selection updates sent to Claude +- **`visual_demotion_delay_ms`**: Time to wait before switching from visual selection to cursor position tracking -The `auto_close` option controls what happens when Claude commands finish: +#### Connection Management -**When `auto_close = true` (default):** +- **`connection_wait_delay`**: Prevents overwhelming Claude with rapid @ mentions after connection +- **`connection_timeout`**: How long to wait for Claude to connect before giving up +- **`queue_timeout`**: How long to keep queued @ mentions before discarding them -- Terminal automatically closes after command completion -- Error notifications shown for failed commands (non-zero exit codes) -- Clean workflow for quick command execution +#### Terminal Configuration -**When `auto_close = false`:** +- **`split_side`**: Which side to open the terminal split (`"left"` or `"right"`) +- **`split_width_percentage`**: Terminal width as a fraction of screen width (0.1 = 10%, 0.5 = 50%) +- **`provider`**: Terminal implementation to use: + - `"auto"`: Try snacks.nvim, fallback to native + - `"snacks"`: Force snacks.nvim (requires folke/snacks.nvim) + - `"native"`: Use built-in Neovim terminal +- **`show_native_term_exit_tip`**: Show help text for exiting native terminal +- **`auto_close`**: Automatically close terminal when commands finish -- Terminal stays open after command completion -- Allows reviewing command output and any error messages -- Useful for debugging or when you want to see detailed output +#### Diff Options + +- **`auto_close_on_accept`**: Close diff view after accepting changes with `:w` or `da` +- **`show_diff_stats`**: Display diff statistics (lines added/removed) +- **`vertical_split`**: Use vertical split layout for diffs +- **`open_in_current_tab`**: Open diffs in current tab instead of creating new tabs + +### Example Configurations + +#### Minimal Configuration ```lua -terminal = { - provider = "snacks", - auto_close = false, -- Keep terminal open to review output +{ + "coder/claudecode.nvim", + opts = { + log_level = "warn", -- Reduce log verbosity + auto_start = false, -- Manual startup only + }, +} +``` + +#### Power User Configuration + +```lua +{ + "coder/claudecode.nvim", + opts = { + log_level = "debug", + visual_demotion_delay_ms = 100, -- Slower selection demotion + connection_wait_delay = 500, -- Longer delay for @ mention batching + terminal = { + split_side = "left", + split_width_percentage = 0.4, -- Wider terminal + provider = "snacks", + auto_close = false, -- Keep terminal open to review output + }, + diff_opts = { + vertical_split = false, -- Horizontal diffs + open_in_current_tab = false, -- New tabs for diffs + }, + }, +} +``` + +#### Custom Claude Installation + +```lua +{ + "coder/claudecode.nvim", + opts = { + terminal_cmd = "/opt/claude/bin/claude", -- Custom Claude path + port_range = { min = 20000, max = 25000 }, -- Different port range + }, } ``` diff --git a/dev-config.lua b/dev-config.lua index 4b73059..533956f 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -33,11 +33,42 @@ return { { "aQ", "ClaudeCodeStop", desc = "Stop Claude Server" }, }, - -- Development configuration + -- Development configuration - all options shown with defaults commented out opts = { - -- auto_start = true, + -- Server Configuration + -- port_range = { min = 10000, max = 65535 }, -- WebSocket server port range + -- auto_start = true, -- Auto-start server on Neovim startup + -- log_level = "info", -- "trace", "debug", "info", "warn", "error" + -- terminal_cmd = nil, -- Custom terminal command (default: "claude") + + -- Selection Tracking + -- track_selection = true, -- Enable real-time selection tracking + -- visual_demotion_delay_ms = 50, -- Delay before demoting visual selection (ms) + + -- Connection Management + -- connection_wait_delay = 200, -- Wait time after connection before sending queued @ mentions (ms) + -- connection_timeout = 10000, -- Max time to wait for Claude Code connection (ms) + -- queue_timeout = 3000, -- Max time to keep @ mentions in queue (ms) + + -- Diff Integration + -- diff_opts = { + -- auto_close_on_accept = true, -- Close diff view after accepting changes + -- 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 + -- }, + + -- Terminal Configuration + -- terminal = { + -- split_side = "right", -- "left" or "right" + -- split_width_percentage = 0.30, -- Width as percentage (0.0 to 1.0) + -- provider = "auto", -- "auto", "snacks", or "native" + -- show_native_term_exit_tip = true, -- Show exit tip for native terminal + -- auto_close = true, -- Auto-close terminal after command completion + -- }, + + -- Development overrides (uncomment as needed) -- log_level = "debug", - -- terminal_cmd = "claude --debug", -- terminal = { -- provider = "native", -- auto_close = false, -- Keep terminals open to see output diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index ee92127..573fc4c 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -9,6 +9,9 @@ M.defaults = { log_level = "info", track_selection = true, visual_demotion_delay_ms = 50, -- Milliseconds to wait before demoting a visual selection + connection_wait_delay = 200, -- Milliseconds to wait after connection before sending queued @ mentions + connection_timeout = 10000, -- Maximum time to wait for Claude Code to connect (milliseconds) + queue_timeout = 5000, -- Maximum time to keep @ mentions in queue (milliseconds) diff_opts = { auto_close_on_accept = true, show_diff_stats = true, @@ -53,6 +56,18 @@ function M.validate(config) "visual_demotion_delay_ms must be a non-negative number" ) + assert( + type(config.connection_wait_delay) == "number" and config.connection_wait_delay >= 0, + "connection_wait_delay must be a non-negative number" + ) + + assert( + type(config.connection_timeout) == "number" and config.connection_timeout > 0, + "connection_timeout must be a positive number" + ) + + assert(type(config.queue_timeout) == "number" and config.queue_timeout > 0, "queue_timeout must be a positive number") + assert(type(config.diff_opts) == "table", "diff_opts must be a table") assert(type(config.diff_opts.auto_close_on_accept) == "boolean", "diff_opts.auto_close_on_accept must be a boolean") assert(type(config.diff_opts.show_diff_stats) == "boolean", "diff_opts.show_diff_stats must be a boolean") diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index d24dc57..429c703 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -39,6 +39,9 @@ M.version = { --- @field log_level "trace"|"debug"|"info"|"warn"|"error" Log level. --- @field track_selection boolean Enable sending selection updates to Claude. --- @field visual_demotion_delay_ms number Milliseconds to wait before demoting a visual selection. +--- @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. --- @type ClaudeCode.Config @@ -49,6 +52,9 @@ local default_config = { log_level = "info", track_selection = true, visual_demotion_delay_ms = 50, -- Reduced from 200ms for better responsiveness in tree navigation + connection_wait_delay = 200, -- Milliseconds to wait after connection before sending queued @ mentions + connection_timeout = 10000, -- Maximum time to wait for Claude Code to connect (milliseconds) + queue_timeout = 5000, -- Maximum time to keep @ mentions in queue (milliseconds) diff_opts = { auto_close_on_accept = true, show_diff_stats = true, @@ -62,6 +68,8 @@ local default_config = { --- @field server table|nil The WebSocket server instance. --- @field port number|nil The port the server is running on. --- @field initialized boolean Whether the plugin has been initialized. +--- @field queued_mentions table[] Array of queued @ mentions waiting for connection. +--- @field connection_timer table|nil Timer for connection timeout. --- @type ClaudeCode.State M.state = { @@ -69,6 +77,8 @@ M.state = { server = nil, port = nil, initialized = false, + queued_mentions = {}, + connection_timer = nil, } ---@alias ClaudeCode.TerminalOpts { \ @@ -79,6 +89,200 @@ M.state = { --- ---@alias ClaudeCode.SetupOpts { \ --- terminal?: ClaudeCode.TerminalOpts } + +---@brief Check if Claude Code is connected to WebSocket server +---@return boolean connected Whether Claude Code has active connections +function M.is_claude_connected() + if not M.state.server then + return false + end + + local server_module = require("claudecode.server.init") + local status = server_module.get_status() + return status.running and status.client_count > 0 +end + +---@brief Clear the @ mention queue and stop timers +local function clear_mention_queue() + if #M.state.queued_mentions > 0 then + logger.debug("queue", "Clearing " .. #M.state.queued_mentions .. " queued @ mentions") + end + + M.state.queued_mentions = {} + + if M.state.connection_timer then + M.state.connection_timer:stop() + M.state.connection_timer:close() + M.state.connection_timer = nil + end +end + +---@brief Add @ mention to queue for later sending +---@param mention_data table The @ mention data to queue +local function queue_at_mention(mention_data) + mention_data.timestamp = vim.loop.now() + table.insert(M.state.queued_mentions, mention_data) + + logger.debug("queue", "Queued @ mention: " .. vim.inspect(mention_data)) + + -- Start connection timer if not already running + if not M.state.connection_timer then + M.state.connection_timer = vim.loop.new_timer() + M.state.connection_timer:start(M.state.config.connection_timeout, 0, function() + vim.schedule(function() + if #M.state.queued_mentions > 0 then + logger.error("queue", "Connection timeout - clearing " .. #M.state.queued_mentions .. " queued @ mentions") + clear_mention_queue() + end + end) + end) + end +end + +---@brief Process queued @ mentions after connection established +function M._process_queued_mentions() + if #M.state.queued_mentions == 0 then + return + end + + logger.debug("queue", "Processing " .. #M.state.queued_mentions .. " queued @ mentions") + + -- Stop connection timer + if M.state.connection_timer then + M.state.connection_timer:stop() + M.state.connection_timer:close() + M.state.connection_timer = nil + end + + -- Wait for connection_wait_delay before sending + vim.defer_fn(function() + local mentions_to_send = vim.deepcopy(M.state.queued_mentions) + M.state.queued_mentions = {} -- Clear queue + + if #mentions_to_send == 0 then + return + end + + -- Ensure terminal is visible when processing queued mentions + local terminal = require("claudecode.terminal") + terminal.ensure_visible() + + local success_count = 0 + local total_count = #mentions_to_send + local delay = 10 -- Use same delay as existing batch operations + + local function send_mentions_sequentially(index) + if index > total_count then + if success_count > 0 then + local message = success_count == 1 and "Sent 1 queued @ mention to Claude Code" + or string.format("Sent %d queued @ mentions to Claude Code", success_count) + logger.debug("queue", message) + end + return + end + + local mention = mentions_to_send[index] + local now = vim.loop.now() + + -- Check if mention hasn't expired + if (now - mention.timestamp) < M.state.config.queue_timeout then + local success, error_msg = M._broadcast_at_mention(mention.file_path, mention.start_line, mention.end_line) + if success then + success_count = success_count + 1 + else + logger.error("queue", "Failed to send queued @ mention: " .. (error_msg or "unknown error")) + end + else + logger.debug("queue", "Skipped expired @ mention: " .. mention.file_path) + end + + -- Send next mention with delay + if index < total_count then + vim.defer_fn(function() + send_mentions_sequentially(index + 1) + end, delay) + else + -- Final summary + if success_count > 0 then + local message = success_count == 1 and "Sent 1 queued @ mention to Claude Code" + or string.format("Sent %d queued @ mentions to Claude Code", success_count) + logger.debug("queue", message) + end + end + end + + send_mentions_sequentially(1) + end, M.state.config.connection_wait_delay) +end + +---@brief Show terminal if Claude is connected and it's not already visible +---@return boolean success Whether terminal was shown or was already visible +function M._ensure_terminal_visible_if_connected() + if not M.is_claude_connected() then + return false + end + + local terminal = require("claudecode.terminal") + local active_bufnr = terminal.get_active_terminal_bufnr and terminal.get_active_terminal_bufnr() + + if not active_bufnr then + return false + end + + local bufinfo = vim.fn.getbufinfo(active_bufnr)[1] + local is_visible = bufinfo and #bufinfo.windows > 0 + + if not is_visible then + terminal.simple_toggle() + end + + return true +end + +---@brief Send @ mention to Claude Code, handling connection state automatically +---@param file_path string The file path to send +---@param start_line number|nil Start line (0-indexed for Claude) +---@param end_line number|nil End line (0-indexed for Claude) +---@param context string|nil Context for logging +---@return boolean success Whether the operation was successful +---@return string|nil error Error message if failed +function M.send_at_mention(file_path, start_line, end_line, context) + context = context or "command" + + if not M.state.server then + logger.error(context, "Claude Code integration is not running") + return false, "Claude Code integration is not running" + end + + -- Check if Claude Code is connected + if M.is_claude_connected() then + -- Claude is connected, send immediately and ensure terminal is visible + local success, error_msg = M._broadcast_at_mention(file_path, start_line, end_line) + if success then + M._ensure_terminal_visible_if_connected() + end + return success, error_msg + else + -- Claude not connected, queue the mention and launch terminal + local mention_data = { + file_path = file_path, + start_line = start_line, + end_line = end_line, + context = context, + } + + queue_at_mention(mention_data) + + -- Launch terminal with Claude Code + local terminal = require("claudecode.terminal") + terminal.open() + + logger.debug(context, "Queued @ mention and launched Claude Code: " .. file_path) + + return true, nil + end +end + --- --- Set up the plugin with user configuration ---@param opts ClaudeCode.SetupOpts|nil Optional configuration table to override defaults. @@ -125,6 +329,9 @@ function M.setup(opts) callback = function() if M.state.server then M.stop() + else + -- Clear queue even if server isn't running + clear_mention_queue() end end, desc = "Automatically stop Claude Code integration when exiting Neovim", @@ -144,7 +351,7 @@ function M.start(show_startup_notification) end if M.state.server then local msg = "Claude Code integration is already running on port " .. tostring(M.state.port) - vim.notify(msg, vim.log.levels.WARN) + logger.warn("init", msg) return false, "Already running" end @@ -152,7 +359,7 @@ function M.start(show_startup_notification) local success, result = server.start(M.state.config) if not success then - vim.notify("Failed to start Claude Code integration: " .. result, vim.log.levels.ERROR) + logger.error("init", "Failed to start Claude Code integration: " .. result) return false, result end @@ -167,7 +374,7 @@ function M.start(show_startup_notification) M.state.server = nil M.state.port = nil - vim.notify("Failed to create lock file: " .. lock_result, vim.log.levels.ERROR) + logger.error("init", "Failed to create lock file: " .. lock_result) return false, lock_result end @@ -177,7 +384,7 @@ function M.start(show_startup_notification) end if show_startup_notification then - vim.notify("Claude Code integration started on port " .. tostring(M.state.port), vim.log.levels.INFO) + logger.info("init", "Claude Code integration started on port " .. tostring(M.state.port)) end return true, M.state.port @@ -188,7 +395,7 @@ end ---@return string? error Error message if operation failed function M.stop() if not M.state.server then - vim.notify("Claude Code integration is not running", vim.log.levels.WARN) + logger.warn("init", "Claude Code integration is not running") return false, "Not running" end @@ -196,7 +403,7 @@ function M.stop() local lock_success, lock_error = lockfile.remove(M.state.port) if not lock_success then - vim.notify("Failed to remove lock file: " .. lock_error, vim.log.levels.WARN) + logger.warn("init", "Failed to remove lock file: " .. lock_error) -- Continue with shutdown even if lock file removal fails end @@ -208,14 +415,17 @@ function M.stop() local success, error = M.state.server.stop() if not success then - vim.notify("Failed to stop Claude Code integration: " .. error, vim.log.levels.ERROR) + logger.error("init", "Failed to stop Claude Code integration: " .. error) return false, error end M.state.server = nil M.state.port = nil - vim.notify("Claude Code integration stopped", vim.log.levels.INFO) + -- Clear any queued @ mentions when server stops + clear_mention_queue() + + logger.info("init", "Claude Code integration stopped") return true end @@ -237,73 +447,14 @@ function M._create_commands() vim.api.nvim_create_user_command("ClaudeCodeStatus", function() if M.state.server and M.state.port then - vim.notify("Claude Code integration is running on port " .. tostring(M.state.port), vim.log.levels.INFO) + logger.info("command", "Claude Code integration is running on port " .. tostring(M.state.port)) else - vim.notify("Claude Code integration is not running", vim.log.levels.INFO) + logger.info("command", "Claude Code integration is not running") end end, { desc = "Show Claude Code integration status", }) - local function format_path_for_at_mention(file_path) - return M._format_path_for_at_mention(file_path) - end - - ---@param file_path string The file path to broadcast - ---@return boolean success Whether the broadcast was successful - ---@return string|nil error Error message if broadcast failed - local function broadcast_at_mention(file_path, start_line, end_line) - if not M.state.server then - return false, "Claude Code integration is not running" - end - - local formatted_path, is_directory - local format_success, format_result, is_dir_result = pcall(format_path_for_at_mention, file_path) - if not format_success then - return false, format_result - end - formatted_path, is_directory = format_result, is_dir_result - - if is_directory and (start_line or end_line) then - logger.debug("command", "Line numbers ignored for directory: " .. formatted_path) - start_line = nil - end_line = nil - end - - local params = { - filePath = formatted_path, - lineStart = start_line, - lineEnd = end_line, - } - - local broadcast_success = M.state.server.broadcast("at_mentioned", params) - if broadcast_success then - if logger.is_level_enabled and logger.is_level_enabled("debug") then - local message = "Broadcast success: Added " .. (is_directory and "directory" or "file") .. " " .. formatted_path - if not is_directory and (start_line or end_line) then - local range_info = "" - if start_line and end_line then - range_info = " (lines " .. start_line .. "-" .. end_line .. ")" - elseif start_line then - range_info = " (from line " .. start_line .. ")" - end - message = message .. range_info - end - logger.debug("command", message) - elseif not logger.is_level_enabled then - logger.debug( - "command", - "Broadcast success: Added " .. (is_directory and "directory" or "file") .. " " .. formatted_path - ) - end - return true, nil - else - local error_msg = "Failed to broadcast " .. (is_directory and "directory" or "file") .. " " .. formatted_path - logger.error("command", error_msg) - return false, error_msg - end - end - ---@param file_paths table List of file paths to add ---@param options table|nil Optional settings: { delay?: number, show_summary?: boolean, context?: string } ---@return number success_count Number of successfully added files @@ -327,23 +478,27 @@ function M._create_commands() if show_summary then local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) - local level = vim.log.levels.INFO - if total_count > success_count then message = message .. string.format(" (%d failed)", total_count - success_count) - level = success_count > 0 and vim.log.levels.WARN or vim.log.levels.ERROR end - if success_count > 0 or total_count > success_count then - vim.notify(message, level) + if total_count > success_count then + if success_count > 0 then + logger.warn(context, message) + else + logger.error(context, message) + end + elseif success_count > 0 then + logger.info(context, message) + else + logger.debug(context, message) end - logger.debug(context, message) end return end local file_path = file_paths[index] - local success, error_msg = broadcast_at_mention(file_path) + local success, error_msg = M.send_at_mention(file_path, nil, nil, context) if success then success_count = success_count + 1 else @@ -358,17 +513,21 @@ function M._create_commands() if show_summary then local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) - local level = vim.log.levels.INFO - if total_count > success_count then message = message .. string.format(" (%d failed)", total_count - success_count) - level = success_count > 0 and vim.log.levels.WARN or vim.log.levels.ERROR end - if success_count > 0 or total_count > success_count then - vim.notify(message, level) + if total_count > success_count then + if success_count > 0 then + logger.warn(context, message) + else + logger.error(context, message) + end + elseif success_count > 0 then + logger.info(context, message) + else + logger.debug(context, message) end - logger.debug(context, message) end end end @@ -376,7 +535,7 @@ function M._create_commands() send_files_sequentially(1) else for _, file_path in ipairs(file_paths) do - local success, error_msg = broadcast_at_mention(file_path) + local success, error_msg = M.send_at_mention(file_path, nil, nil, context) if success then success_count = success_count + 1 else @@ -398,12 +557,6 @@ function M._create_commands() end local function handle_send_normal(opts) - if not M.state.server then - logger.error("command", "ClaudeCodeSend: Claude Code integration is not running.") - vim.notify("Claude Code integration is not running", vim.log.levels.ERROR) - return - end - local current_ft = (vim.bo and vim.bo.filetype) or "" local current_bufname = (vim.api and vim.api.nvim_buf_get_name and vim.api.nvim_buf_get_name(0)) or "" @@ -418,14 +571,12 @@ function M._create_commands() local files, error = integrations.get_selected_files_from_tree() if error then - logger.warn("command", "ClaudeCodeSend->TreeAdd: " .. error) - vim.notify("Tree integration error: " .. error, vim.log.levels.ERROR) + logger.error("command", "ClaudeCodeSend->TreeAdd: " .. error) return end if not files or #files == 0 then logger.warn("command", "ClaudeCodeSend->TreeAdd: No files selected") - vim.notify("No files selected in tree explorer", vim.log.levels.WARN) return end @@ -453,16 +604,11 @@ function M._create_commands() end else logger.error("command", "ClaudeCodeSend: Failed to load selection module.") - vim.notify("Failed to send selection: selection module not loaded.", vim.log.levels.ERROR) end end - local function handle_send_visual(visual_data, opts) - if not M.state.server then - logger.error("command", "ClaudeCodeSend_visual: Claude Code integration is not running.") - return - end - + local function handle_send_visual(visual_data, _opts) + -- Try tree file selection first if visual_data then local visual_commands = require("claudecode.visual_commands") local files, error = visual_commands.get_files_from_visual_selection(visual_data) @@ -481,8 +627,18 @@ function M._create_commands() return end end + + -- Handle regular text selection using range from visual mode local selection_module_ok, selection_module = pcall(require, "claudecode.selection") - if selection_module_ok then + if not selection_module_ok then + return + end + + -- Use the marks left by visual mode instead of trying to get current visual selection + local line1, line2 = vim.fn.line("'<"), vim.fn.line("'>") + if line1 and line2 and line1 > 0 and line2 > 0 then + selection_module.send_at_mention_for_visual_selection(line1, line2) + else selection_module.send_at_mention_for_visual_selection() end end @@ -505,7 +661,7 @@ function M._create_commands() local files, error = integrations.get_selected_files_from_tree() if error then - logger.warn("command", "ClaudeCodeTreeAdd: " .. error) + logger.error("command", "ClaudeCodeTreeAdd: " .. error) return end @@ -514,10 +670,31 @@ function M._create_commands() return end - local success_count = add_paths_to_claude(files, { context = "ClaudeCodeTreeAdd" }) + -- Use connection-aware broadcasting for each file + local success_count = 0 + local total_count = #files + + for _, file_path in ipairs(files) do + local success, error_msg = M.send_at_mention(file_path, nil, nil, "ClaudeCodeTreeAdd") + if success then + success_count = success_count + 1 + else + logger.error( + "command", + "ClaudeCodeTreeAdd: Failed to add file: " .. file_path .. " - " .. (error_msg or "unknown error") + ) + end + end if success_count == 0 then logger.error("command", "ClaudeCodeTreeAdd: Failed to add any files") + elseif success_count < total_count then + local message = string.format("Added %d/%d files to Claude context", success_count, total_count) + logger.debug("command", message) + else + local message = success_count == 1 and "Added 1 file to Claude context" + or string.format("Added %d files to Claude context", success_count) + logger.debug("command", message) end end @@ -531,7 +708,7 @@ function M._create_commands() local files, error = visual_cmd_module.get_files_from_visual_selection(visual_data) if error then - logger.warn("command", "ClaudeCodeTreeAdd_visual: " .. error) + logger.error("command", "ClaudeCodeTreeAdd_visual: " .. error) return end @@ -540,15 +717,30 @@ function M._create_commands() return end - local success_count = add_paths_to_claude(files, { - delay = 10, - context = "ClaudeCodeTreeAdd_visual", - show_summary = false, - }) + -- Use connection-aware broadcasting for each file + local success_count = 0 + local total_count = #files + + for _, file_path in ipairs(files) do + local success, error_msg = M.send_at_mention(file_path, nil, nil, "ClaudeCodeTreeAdd_visual") + if success then + success_count = success_count + 1 + else + logger.error( + "command", + "ClaudeCodeTreeAdd_visual: Failed to add file: " .. file_path .. " - " .. (error_msg or "unknown error") + ) + end + end + if success_count > 0 then local message = success_count == 1 and "Added 1 file to Claude context from visual selection" or string.format("Added %d files to Claude context from visual selection", success_count) logger.debug("command", message) + + if success_count < total_count then + logger.warn("command", string.format("Added %d/%d files from visual selection", success_count, total_count)) + end else logger.error("command", "ClaudeCodeTreeAdd_visual: Failed to add any files from visual selection") end @@ -622,7 +814,7 @@ function M._create_commands() local claude_start_line = start_line and (start_line - 1) or nil local claude_end_line = end_line and (end_line - 1) or nil - local success, error_msg = broadcast_at_mention(file_path, claude_start_line, claude_end_line) + local success, error_msg = M.send_at_mention(file_path, claude_start_line, claude_end_line, "ClaudeCodeAdd") if not success then logger.error("command", "ClaudeCodeAdd: " .. (error_msg or "Failed to add file")) else @@ -798,10 +990,6 @@ function M._add_paths_to_claude(file_paths, options) if #file_paths > max_files then logger.warn(context, string.format("Too many files selected (%d), limiting to %d", #file_paths, max_files)) - vim.notify( - string.format("Too many files selected (%d), processing first %d", #file_paths, max_files), - vim.log.levels.WARN - ) local limited_paths = {} for i = 1, max_files do limited_paths[i] = file_paths[i] @@ -818,17 +1006,21 @@ function M._add_paths_to_claude(file_paths, options) if show_summary then local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) - local level = vim.log.levels.INFO - if total_count > success_count then message = message .. string.format(" (%d failed)", total_count - success_count) - level = success_count > 0 and vim.log.levels.WARN or vim.log.levels.ERROR end - if success_count > 0 or total_count > success_count then - vim.notify(message, level) + if total_count > success_count then + if success_count > 0 then + logger.warn(context, message) + else + logger.error(context, message) + end + elseif success_count > 0 then + logger.info(context, message) + else + logger.debug(context, message) end - logger.debug(context, message) end return end @@ -867,17 +1059,21 @@ function M._add_paths_to_claude(file_paths, options) if show_summary then local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) - local level = vim.log.levels.INFO - if total_count > success_count then message = message .. string.format(" (%d failed)", total_count - success_count) - level = success_count > 0 and vim.log.levels.WARN or vim.log.levels.ERROR end - if success_count > 0 or total_count > success_count then - vim.notify(message, level) + if total_count > success_count then + if success_count > 0 then + logger.warn(context, message) + else + logger.error(context, message) + end + elseif success_count > 0 then + logger.info(context, message) + else + logger.debug(context, message) end - logger.debug(context, message) end end end @@ -905,17 +1101,21 @@ function M._add_paths_to_claude(file_paths, options) if show_summary then local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) - local level = vim.log.levels.INFO - if total_count > success_count then message = message .. string.format(" (%d failed)", total_count - success_count) - level = success_count > 0 and vim.log.levels.WARN or vim.log.levels.ERROR end - if success_count > 0 or total_count > success_count then - vim.notify(message, level) + if total_count > success_count then + if success_count > 0 then + logger.warn(context, message) + else + logger.error(context, message) + end + elseif success_count > 0 then + logger.info(context, message) + else + logger.debug(context, message) end - logger.debug(context, message) end end diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index ec41134..bcd0f10 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -629,8 +629,15 @@ end -- @param line1 number|nil Optional start line for range-based selection -- @param line2 number|nil Optional end line for range-based selection function M.send_at_mention_for_visual_selection(line1, line2) - if not M.state.tracking_enabled or not M.server then - logger.error("selection", "Claude Code is not running or server not available for send_at_mention.") + if not M.state.tracking_enabled then + logger.error("selection", "Selection tracking is not enabled.") + return false + end + + -- Check if Claude Code integration is running (server may or may not have clients) + local claudecode_main = require("claudecode") + if not claudecode_main.state.server then + logger.error("selection", "Claude Code integration is not running.") return false end @@ -663,31 +670,31 @@ function M.send_at_mention_for_visual_selection(line1, line2) -- Sanity check: ensure the selection is for the current buffer local current_buf_name = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf()) if sel_to_send.filePath ~= current_buf_name then - vim.notify( + logger.warn( + "selection", "Tracked selection is for '" .. sel_to_send.filePath .. "', but current buffer is '" .. current_buf_name - .. "'. Not sending.", - vim.log.levels.WARN, - { title = "ClaudeCode Warning" } + .. "'. Not sending." ) return false end - local params = {} - params["filePath"] = sel_to_send.filePath - params["lineStart"] = sel_to_send.selection.start.line -- Assuming 0-indexed from selection module - params["lineEnd"] = sel_to_send.selection["end"].line -- Assuming 0-indexed + -- Use connection-aware broadcasting from main module + local file_path = sel_to_send.filePath + local start_line = sel_to_send.selection.start.line -- Already 0-indexed from selection module + local end_line = sel_to_send.selection["end"].line -- Already 0-indexed - local broadcast_success = M.server.broadcast("at_mentioned", params) + local success, error_msg = claudecode_main.send_at_mention(file_path, start_line, end_line, "ClaudeCodeSend") - if not broadcast_success then - logger.error("selection", "Failed to send at-mention.") - return false - else + if success then logger.debug("selection", "Visual selection sent as at-mention.") + return true + else + logger.error("selection", "Failed to send at-mention: " .. (error_msg or "unknown error")) + return false end end return M diff --git a/lua/claudecode/server/init.lua b/lua/claudecode/server/init.lua index f5d179a..0d764ef 100644 --- a/lua/claudecode/server/init.lua +++ b/lua/claudecode/server/init.lua @@ -42,6 +42,14 @@ function M.start(config) on_connect = function(client) M.state.clients[client.id] = client logger.debug("server", "WebSocket client connected:", client.id) + + -- Notify main module about new connection for queue processing + local main_module = require("claudecode") + if main_module._process_queued_mentions then + vim.schedule(function() + main_module._process_queued_mentions() + end) + end end, on_disconnect = function(client, code, reason) M.state.clients[client.id] = nil diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 67ca822..d24d5d5 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -7,6 +7,8 @@ --- @field open function --- @field close function --- @field toggle function +--- @field simple_toggle function +--- @field focus_toggle function --- @field get_active_bufnr function --- @field is_available function --- @field _get_terminal_for_test function @@ -52,7 +54,6 @@ local function get_provider() -- Try snacks first, then fallback to native silently local snacks_provider = load_provider("snacks") if snacks_provider and snacks_provider.is_available() then - logger.debug("terminal", "Auto-detected snacks terminal provider") return snacks_provider end -- Fall through to native provider @@ -185,8 +186,7 @@ function M.setup(user_term_config, p_terminal_cmd) end -- Setup providers with config - local provider = get_provider() - provider.setup(config) + get_provider().setup(config) end --- Opens or focuses the Claude terminal. @@ -224,6 +224,55 @@ function M.focus_toggle(opts_override, cmd_args) get_provider().focus_toggle(cmd_string, claude_env_table, effective_config) end +--- Toggle open terminal without focus if not already visible, otherwise do nothing. +-- @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_open_no_focus(opts_override, cmd_args) + local provider = get_provider() + + -- Check if terminal is already visible by checking active buffer + local active_bufnr = provider.get_active_bufnr() + if active_bufnr then + -- Check if buffer is currently visible in any window + local bufinfo = vim.fn.getbufinfo(active_bufnr) + if bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 then + -- Terminal is already visible, do nothing + return + end + end + + -- Terminal is not visible, open it without focus + local effective_config = build_config(opts_override) + local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) + + provider.open(cmd_string, claude_env_table, effective_config) +end + +--- Ensures terminal is visible without changing focus. Creates if necessary, shows if hidden. +-- @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.ensure_visible(opts_override, cmd_args) + local provider = get_provider() + local effective_config = build_config(opts_override) + local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) + + -- Check if terminal exists and is visible + local active_bufnr = provider.get_active_bufnr() + if active_bufnr then + local bufinfo = vim.fn.getbufinfo(active_bufnr) + if bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 then + -- Terminal is already visible, do nothing + return + end + -- Terminal exists but not visible, use open() to show it + -- Using open() instead of simple_toggle() to avoid toggle conflicts when called rapidly + provider.open(cmd_string, claude_env_table, effective_config) + else + -- No terminal exists, create one + provider.open(cmd_string, claude_env_table, effective_config) + end +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. diff --git a/tests/config_test.lua b/tests/config_test.lua index 4802719..7d1d095 100644 --- a/tests/config_test.lua +++ b/tests/config_test.lua @@ -183,6 +183,9 @@ describe("Config module", function() log_level = "debug", track_selection = false, visual_demotion_delay_ms = 50, + connection_wait_delay = 200, + connection_timeout = 10000, + queue_timeout = 3000, diff_opts = { auto_close_on_accept = true, show_diff_stats = true, diff --git a/tests/selection_test.lua b/tests/selection_test.lua index 6d7e14b..1ecf0f6 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -563,6 +563,8 @@ describe("Range Selection Tests", function() describe("send_at_mention_for_visual_selection with range", function() local mock_server + local mock_claudecode_main + local original_require before_each(function() mock_server = { @@ -575,10 +577,39 @@ describe("Range Selection Tests", function() end, } + mock_claudecode_main = { + state = { + server = mock_server, + }, + send_at_mention = function(file_path, start_line, end_line, context) + -- Convert to the format expected by tests (1-indexed to 0-indexed conversion done here) + local params = { + filePath = file_path, + lineStart = start_line, + lineEnd = end_line, + } + return mock_server.broadcast("at_mentioned", params), nil + end, + } + + -- Mock the require function to return our mock claudecode module + original_require = _G.require + _G.require = function(module_name) + if module_name == "claudecode" then + return mock_claudecode_main + else + return original_require(module_name) + end + end + selection.state.tracking_enabled = true selection.server = mock_server end) + after_each(function() + _G.require = original_require + end) + it("should send range selection successfully", function() local result = selection.send_at_mention_for_visual_selection(2, 4) @@ -616,7 +647,7 @@ describe("Range Selection Tests", function() end) it("should fail when server is not available", function() - selection.server = nil + mock_claudecode_main.state.server = nil local result = selection.send_at_mention_for_visual_selection(2, 4) assert(result == false) end) diff --git a/tests/unit/at_mention_edge_cases_spec.lua b/tests/unit/at_mention_edge_cases_spec.lua index 89b71d7..79a9872 100644 --- a/tests/unit/at_mention_edge_cases_spec.lua +++ b/tests/unit/at_mention_edge_cases_spec.lua @@ -13,8 +13,16 @@ describe("At Mention Edge Cases", function() -- Mock logger package.loaded["claudecode.logger"] = { debug = function() end, - warn = function() end, - error = function() end, + warn = function(component, ...) + local args = { ... } + local message = table.concat(args, " ") + _G.vim.notify(message, _G.vim.log.levels.WARN) + end, + error = function(component, ...) + local args = { ... } + local message = table.concat(args, " ") + _G.vim.notify(message, _G.vim.log.levels.ERROR) + end, } -- Mock config diff --git a/tests/unit/claudecode_add_command_spec.lua b/tests/unit/claudecode_add_command_spec.lua index 3d8b9d1..5f98f65 100644 --- a/tests/unit/claudecode_add_command_spec.lua +++ b/tests/unit/claudecode_add_command_spec.lua @@ -49,6 +49,10 @@ describe("ClaudeCodeAdd command", function() return "/current/dir" end + vim.fn.getbufinfo = function(bufnr) + return { { windows = { 1 } } } + end + vim.api.nvim_create_user_command = spy.new(function() end) vim.api.nvim_buf_get_name = function() return "test.lua" @@ -70,9 +74,22 @@ describe("ClaudeCodeAdd command", function() return { setup = function() end, } + elseif mod == "claudecode.server.init" then + return { + get_status = function() + return { running = true, client_count = 1 } + end, + } elseif mod == "claudecode.terminal" then return { setup = function() end, + open = spy.new(function() end), + toggle_open_no_focus = spy.new(function() end), + ensure_visible = spy.new(function() end), + get_active_terminal_bufnr = function() + return 1 + end, + simple_toggle = spy.new(function() end), } elseif mod == "claudecode.visual_commands" then return { diff --git a/tests/unit/claudecode_send_command_spec.lua b/tests/unit/claudecode_send_command_spec.lua index 61f6220..e8c8092 100644 --- a/tests/unit/claudecode_send_command_spec.lua +++ b/tests/unit/claudecode_send_command_spec.lua @@ -71,6 +71,7 @@ describe("ClaudeCodeSend Command Range Functionality", function() -- Mock terminal module mock_terminal = { open = spy.new(function() end), + ensure_visible = spy.new(function() end), } -- Mock server @@ -246,8 +247,8 @@ describe("ClaudeCodeSend Command Range Functionality", function() command_callback(opts) - assert.spy(_G.vim.notify).was_called() - assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_not_called() + -- The command should call the selection module, which will handle the error + assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_called() end) it("should handle selection module failure", function() diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 5801811..5be3e37 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -32,6 +32,9 @@ describe("Configuration", function() log_level = "debug", track_selection = false, visual_demotion_delay_ms = 50, + connection_wait_delay = 200, + connection_timeout = 10000, + queue_timeout = 3000, diff_opts = { auto_close_on_accept = true, show_diff_stats = true, diff --git a/tests/unit/init_spec.lua b/tests/unit/init_spec.lua index fdf5ba1..8840ea0 100644 --- a/tests/unit/init_spec.lua +++ b/tests/unit/init_spec.lua @@ -92,6 +92,7 @@ describe("claudecode.init", function() return 1 end), nvim_create_user_command = SpyObject.new(function() end), + nvim_echo = SpyObject.new(function() end), } vim.deepcopy = function(t) @@ -296,6 +297,7 @@ describe("claudecode.init", function() open = spy.new(function() end), close = spy.new(function() end), setup = spy.new(function() end), + ensure_visible = spy.new(function() end), } local original_require = _G.require From ff08290d1ca7251550a40839083596684db3eb31 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Jun 2025 14:32:45 +0200 Subject: [PATCH 3/8] feat: enhance terminal visibility checks and add oil.nvim support - Refactor terminal visibility logic into reusable is_terminal_visible() function - Simplify ensure_visible() logic to use consistent visibility checks - Add oil.nvim support to file tree keybindings in README examples - Update dev-config.lua to include oil.nvim in supported file types - Fix terminal focus handling in send_at_mention to use terminal.ensure_visible() Change-Id: Ia5655ef53139d995330cfaa03ac347195678165f Signed-off-by: Thomas Kosiewski --- README.md | 73 ++++++++++++++++++++++++++++++++++++- dev-config.lua | 2 +- lua/claudecode/init.lua | 3 +- lua/claudecode/terminal.lua | 42 +++++++++++---------- 4 files changed, 96 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 26ac98b..0a0dffc 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Using [lazy.nvim](https://github.com/folke/lazy.nvim): "as", "ClaudeCodeTreeAdd", desc = "Add file", - ft = { "NvimTree", "neo-tree" }, + ft = { "NvimTree", "neo-tree", "oil" }, }, }, } @@ -227,6 +227,20 @@ For most users, the default configuration is sufficient: "folke/snacks.nvim", -- optional }, config = true, + keys = { + { "a", nil, desc = "AI/Claude Code" }, + { "ac", "ClaudeCode", desc = "Toggle Claude" }, + { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, + { "ar", "ClaudeCode --resume", desc = "Resume Claude" }, + { "aC", "ClaudeCode --continue", desc = "Continue Claude" }, + { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, + { + "as", + "ClaudeCodeTreeAdd", + desc = "Add file", + ft = { "NvimTree", "neo-tree", "oil" }, + }, + }, } ``` @@ -241,6 +255,20 @@ For most users, the default configuration is sufficient: dependencies = { "folke/snacks.nvim", -- Optional for enhanced terminal }, + keys = { + { "a", nil, desc = "AI/Claude Code" }, + { "ac", "ClaudeCode", desc = "Toggle Claude" }, + { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, + { "ar", "ClaudeCode --resume", desc = "Resume Claude" }, + { "aC", "ClaudeCode --continue", desc = "Continue Claude" }, + { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, + { + "as", + "ClaudeCodeTreeAdd", + desc = "Add file", + ft = { "NvimTree", "neo-tree", "oil" }, + }, + }, opts = { -- Server Configuration port_range = { min = 10000, max = 65535 }, -- WebSocket server port range @@ -274,7 +302,6 @@ For most users, the default configuration is sufficient: open_in_current_tab = true, -- Open diffs in current tab vs new tab }, }, - config = true, } ``` @@ -325,6 +352,20 @@ For most users, the default configuration is sufficient: ```lua { "coder/claudecode.nvim", + keys = { + { "a", nil, desc = "AI/Claude Code" }, + { "ac", "ClaudeCode", desc = "Toggle Claude" }, + { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, + { "ar", "ClaudeCode --resume", desc = "Resume Claude" }, + { "aC", "ClaudeCode --continue", desc = "Continue Claude" }, + { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, + { + "as", + "ClaudeCodeTreeAdd", + desc = "Add file", + ft = { "NvimTree", "neo-tree", "oil" }, + }, + }, opts = { log_level = "warn", -- Reduce log verbosity auto_start = false, -- Manual startup only @@ -337,6 +378,20 @@ For most users, the default configuration is sufficient: ```lua { "coder/claudecode.nvim", + keys = { + { "a", nil, desc = "AI/Claude Code" }, + { "ac", "ClaudeCode", desc = "Toggle Claude" }, + { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, + { "ar", "ClaudeCode --resume", desc = "Resume Claude" }, + { "aC", "ClaudeCode --continue", desc = "Continue Claude" }, + { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, + { + "as", + "ClaudeCodeTreeAdd", + desc = "Add file", + ft = { "NvimTree", "neo-tree", "oil" }, + }, + }, opts = { log_level = "debug", visual_demotion_delay_ms = 100, -- Slower selection demotion @@ -360,6 +415,20 @@ For most users, the default configuration is sufficient: ```lua { "coder/claudecode.nvim", + keys = { + { "a", nil, desc = "AI/Claude Code" }, + { "ac", "ClaudeCode", desc = "Toggle Claude" }, + { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, + { "ar", "ClaudeCode --resume", desc = "Resume Claude" }, + { "aC", "ClaudeCode --continue", desc = "Continue Claude" }, + { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, + { + "as", + "ClaudeCodeTreeAdd", + desc = "Add file", + ft = { "NvimTree", "neo-tree", "oil" }, + }, + }, opts = { terminal_cmd = "/opt/claude/bin/claude", -- Custom Claude path port_range = { min = 20000, max = 25000 }, -- Different port range diff --git a/dev-config.lua b/dev-config.lua index 533956f..4742d7e 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -22,7 +22,7 @@ return { "as", "ClaudeCodeTreeAdd", desc = "Add file from tree", - ft = { "NvimTree", "neo-tree" }, + ft = { "NvimTree", "neo-tree", "oil" }, }, -- Development helpers diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 429c703..9547ef6 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -259,7 +259,8 @@ function M.send_at_mention(file_path, start_line, end_line, context) -- Claude is connected, send immediately and ensure terminal is visible local success, error_msg = M._broadcast_at_mention(file_path, start_line, end_line) if success then - M._ensure_terminal_visible_if_connected() + local terminal = require("claudecode.terminal") + terminal.ensure_visible() end return success, error_msg else diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index d24d5d5..05138f9 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -105,6 +105,18 @@ local function build_config(opts_override) } end +--- Checks if a terminal buffer is currently visible in any window +--- @param bufnr number|nil The buffer number to check +--- @return boolean True if the buffer is visible in any window, false otherwise +local function is_terminal_visible(bufnr) + if not bufnr then + return false + end + + local bufinfo = vim.fn.getbufinfo(bufnr) + return bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 +end + --- Gets the claude command string and necessary environment variables --- @param cmd_args string|nil Optional arguments to append to the command --- @return string cmd_string The command string @@ -230,15 +242,11 @@ end function M.toggle_open_no_focus(opts_override, cmd_args) local provider = get_provider() - -- Check if terminal is already visible by checking active buffer + -- Check if terminal is already visible local active_bufnr = provider.get_active_bufnr() - if active_bufnr then - -- Check if buffer is currently visible in any window - local bufinfo = vim.fn.getbufinfo(active_bufnr) - if bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 then - -- Terminal is already visible, do nothing - return - end + if is_terminal_visible(active_bufnr) then + -- Terminal is already visible, do nothing + return end -- Terminal is not visible, open it without focus @@ -258,19 +266,13 @@ function M.ensure_visible(opts_override, cmd_args) -- Check if terminal exists and is visible local active_bufnr = provider.get_active_bufnr() - if active_bufnr then - local bufinfo = vim.fn.getbufinfo(active_bufnr) - if bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 then - -- Terminal is already visible, do nothing - return - end - -- Terminal exists but not visible, use open() to show it - -- Using open() instead of simple_toggle() to avoid toggle conflicts when called rapidly - provider.open(cmd_string, claude_env_table, effective_config) - else - -- No terminal exists, create one - provider.open(cmd_string, claude_env_table, effective_config) + if is_terminal_visible(active_bufnr) then + -- Terminal is already visible, do nothing + return end + + -- Terminal is not visible or doesn't exist, create/show it + provider.open(cmd_string, claude_env_table, effective_config) end --- Toggles the Claude terminal open or closed (legacy function - use simple_toggle or focus_toggle). From bcf7e4325e664e2d8998bbc099bb9f4965d552eb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Jun 2025 14:44:54 +0200 Subject: [PATCH 4/8] fix: address Copilot feedback on terminal focus behavior - Add optional focus parameter to both snacks and native provider open() methods - Extract shared logic into ensure_terminal_visible_no_focus() helper function - Fix ensure_visible() to truly not focus by passing focus=false to providers - Fix toggle_open_no_focus() to use the same consistent no-focus behavior - Maintain backward compatibility by defaulting focus parameter to true - Return to original window in native provider when focus=false Addresses feedback about ensure_visible() contradicting its intent by calling provider.open() which always focused the terminal. Change-Id: I11635699095df7284232b7c959ade1e11c41dbc4 Signed-off-by: Thomas Kosiewski --- README.md | 2 +- dev-config.lua | 2 +- lua/claudecode/terminal.lua | 50 ++++++++++++++---------------- lua/claudecode/terminal/native.lua | 34 ++++++++++++++------ lua/claudecode/terminal/snacks.lua | 33 ++++++++++++-------- 5 files changed, 70 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 0a0dffc..3eceda0 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ For most users, the default configuration is sufficient: -- Connection Management connection_wait_delay = 200, -- Wait time after connection before sending queued @ mentions (ms) connection_timeout = 10000, -- Max time to wait for Claude Code connection (ms) - queue_timeout = 3000, -- Max time to keep @ mentions in queue (ms) + queue_timeout = 5000, -- Max time to keep @ mentions in queue (ms) -- Terminal Configuration terminal = { diff --git a/dev-config.lua b/dev-config.lua index 4742d7e..525e61e 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -48,7 +48,7 @@ return { -- Connection Management -- connection_wait_delay = 200, -- Wait time after connection before sending queued @ mentions (ms) -- connection_timeout = 10000, -- Max time to wait for Claude Code connection (ms) - -- queue_timeout = 3000, -- Max time to keep @ mentions in queue (ms) + -- queue_timeout = 5000, -- Max time to keep @ mentions in queue (ms) -- Diff Integration -- diff_opts = { diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 05138f9..896a5da 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -151,6 +151,27 @@ local function get_claude_command_and_env(cmd_args) return cmd_string, env_table end +--- Common helper to open terminal without focus if not already visible +--- @param opts_override table|nil Optional config overrides +--- @param cmd_args string|nil Optional command arguments +--- @return boolean True if terminal was opened or already visible +local function ensure_terminal_visible_no_focus(opts_override, cmd_args) + local provider = get_provider() + local active_bufnr = provider.get_active_bufnr() + + if is_terminal_visible(active_bufnr) then + -- Terminal is already visible, do nothing + return true + end + + -- Terminal is not visible, open it without focus + local effective_config = build_config(opts_override) + local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) + + provider.open(cmd_string, claude_env_table, effective_config, false) -- false = don't focus + return true +end + --- Configures the terminal module. -- Merges user-provided terminal configuration with defaults and sets the terminal command. -- @param user_term_config table (optional) Configuration options for the terminal. @@ -240,39 +261,14 @@ end -- @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_open_no_focus(opts_override, cmd_args) - local provider = get_provider() - - -- Check if terminal is already visible - local active_bufnr = provider.get_active_bufnr() - if is_terminal_visible(active_bufnr) then - -- Terminal is already visible, do nothing - return - end - - -- Terminal is not visible, open it without focus - local effective_config = build_config(opts_override) - local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) - - provider.open(cmd_string, claude_env_table, effective_config) + ensure_terminal_visible_no_focus(opts_override, cmd_args) end --- Ensures terminal is visible without changing focus. Creates if necessary, shows if hidden. -- @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.ensure_visible(opts_override, cmd_args) - local provider = get_provider() - local effective_config = build_config(opts_override) - local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) - - -- Check if terminal exists and is visible - local active_bufnr = provider.get_active_bufnr() - if is_terminal_visible(active_bufnr) then - -- Terminal is already visible, do nothing - return - end - - -- Terminal is not visible or doesn't exist, create/show it - provider.open(cmd_string, claude_env_table, effective_config) + ensure_terminal_visible_no_focus(opts_override, cmd_args) end --- Toggles the Claude terminal open or closed (legacy function - use simple_toggle or focus_toggle). diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index a7f5b22..4558f76 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -45,10 +45,14 @@ local function is_valid() return true end -local function open_terminal(cmd_string, env_table, effective_config) +local function open_terminal(cmd_string, env_table, effective_config, focus) + focus = focus == nil and true or focus -- Default to true for backward compatibility + if is_valid() then -- Should not happen if called correctly, but as a safeguard - vim.api.nvim_set_current_win(winid) - vim.cmd("startinsert") + if focus then + vim.api.nvim_set_current_win(winid) + vim.cmd("startinsert") + end return true end @@ -121,8 +125,13 @@ local function open_terminal(cmd_string, env_table, effective_config) vim.bo[bufnr].bufhidden = "wipe" -- Wipe buffer when hidden (e.g., window closed) -- buftype=terminal is set by termopen - vim.api.nvim_set_current_win(winid) - vim.cmd("startinsert") + if focus then + vim.api.nvim_set_current_win(winid) + vim.cmd("startinsert") + else + -- Return to original window if not focusing + vim.api.nvim_set_current_win(original_win) + end if config.show_native_term_exit_tip and not tip_shown then vim.notify("Native terminal opened. Press Ctrl-\\ Ctrl-N to return to Normal mode.", vim.log.levels.INFO) @@ -251,9 +260,14 @@ end --- @param cmd_string string --- @param env_table table --- @param effective_config table -function M.open(cmd_string, env_table, effective_config) +--- @param focus boolean|nil +function M.open(cmd_string, env_table, effective_config, focus) + focus = focus == nil and true or focus -- Default to true for backward compatibility + if is_valid() then - focus_terminal() + if focus then + focus_terminal() + end else -- Check if there's an existing Claude terminal we lost track of local existing_buf, existing_win = find_existing_claude_terminal() @@ -263,9 +277,11 @@ function M.open(cmd_string, env_table, effective_config) winid = existing_win -- Note: We can't recover the job ID easily, but it's less critical logger.debug("terminal", "Recovered existing Claude terminal") - focus_terminal() + if focus then + focus_terminal() + end else - if not open_terminal(cmd_string, env_table, effective_config) then + if not open_terminal(cmd_string, env_table, effective_config, focus) then vim.notify("Failed to open Claude terminal using native fallback.", vim.log.levels.ERROR) end end diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 129062a..1caaf62 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -43,12 +43,14 @@ end --- @param config table --- @param env_table table +--- @param focus boolean|nil --- @return table -local function build_opts(config, env_table) +local function build_opts(config, env_table, focus) + focus = focus == nil and true or focus -- Default to true for backward compatibility return { env = env_table, - start_insert = true, - auto_insert = true, + start_insert = focus, + auto_insert = focus, auto_close = false, win = { position = config.split_side, @@ -66,27 +68,32 @@ end --- @param cmd_string string --- @param env_table table --- @param config table -function M.open(cmd_string, env_table, config) +--- @param focus boolean|nil +function M.open(cmd_string, env_table, config, focus) if not is_available() then vim.notify("Snacks.nvim terminal provider selected but Snacks.terminal not available.", vim.log.levels.ERROR) return end + focus = focus == nil and true or focus -- Default to true for backward compatibility + if terminal and terminal:buf_valid() then - terminal:focus() - local term_buf_id = terminal.buf - if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then - -- Check if window is valid before calling nvim_win_call - if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then - vim.api.nvim_win_call(terminal.win, function() - vim.cmd("startinsert") - end) + if focus then + terminal:focus() + local term_buf_id = terminal.buf + if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then + -- Check if window is valid before calling nvim_win_call + if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then + vim.api.nvim_win_call(terminal.win, function() + vim.cmd("startinsert") + end) + end end end return end - local opts = build_opts(config, env_table) + local opts = build_opts(config, env_table, focus) local term_instance = Snacks.terminal.open(cmd_string, opts) if term_instance and term_instance:buf_valid() then setup_terminal_events(term_instance, config) From 59456342d5fbc5bfaecafff6857ddf136e23adea Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Jun 2025 14:59:25 +0200 Subject: [PATCH 5/8] refactor: extract focus normalization logic into shared helper - Add normalize_focus() helper function in both snacks and native providers - Replace duplicated focus defaulting logic with consistent helper calls - Remove unused normalize_focus() from main terminal.lua file - Maintain backward compatibility by defaulting focus to true - Clean up linting warnings This addresses the Copilot feedback about duplicated focus defaulting logic across both providers and simplifies future maintenance. Change-Id: I8387f264d85a3c097081f7976bc0a34b9b52485c Signed-off-by: Thomas Kosiewski --- lua/claudecode/terminal/native.lua | 11 +++++++++-- lua/claudecode/terminal/snacks.lua | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 4558f76..9b3160d 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -6,6 +6,13 @@ local M = {} local logger = require("claudecode.logger") +--- Normalizes focus parameter to default to true for backward compatibility +--- @param focus boolean|nil The focus parameter +--- @return boolean Normalized focus value +local function normalize_focus(focus) + return focus == nil and true or focus +end + local bufnr = nil local winid = nil local jobid = nil @@ -46,7 +53,7 @@ local function is_valid() end local function open_terminal(cmd_string, env_table, effective_config, focus) - focus = focus == nil and true or focus -- Default to true for backward compatibility + focus = normalize_focus(focus) if is_valid() then -- Should not happen if called correctly, but as a safeguard if focus then @@ -262,7 +269,7 @@ end --- @param effective_config table --- @param focus boolean|nil function M.open(cmd_string, env_table, effective_config, focus) - focus = focus == nil and true or focus -- Default to true for backward compatibility + focus = normalize_focus(focus) if is_valid() then if focus then diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 1caaf62..0be6a10 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -41,12 +41,19 @@ local function setup_terminal_events(term_instance, config) end, { buf = true }) end +--- Normalizes focus parameter to default to true for backward compatibility +--- @param focus boolean|nil The focus parameter +--- @return boolean Normalized focus value +local function normalize_focus(focus) + return focus == nil and true or focus +end + --- @param config table --- @param env_table table --- @param focus boolean|nil --- @return table local function build_opts(config, env_table, focus) - focus = focus == nil and true or focus -- Default to true for backward compatibility + focus = normalize_focus(focus) return { env = env_table, start_insert = focus, @@ -75,7 +82,7 @@ function M.open(cmd_string, env_table, config, focus) return end - focus = focus == nil and true or focus -- Default to true for backward compatibility + focus = normalize_focus(focus) if terminal and terminal:buf_valid() then if focus then From 8dfb5649508203a233c591d45fd4c6ea36da727c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Jun 2025 15:05:36 +0200 Subject: [PATCH 6/8] refactor: centralize normalize_focus and fix config inconsistencies - Create shared claudecode.utils module with normalize_focus() function - Remove duplicated normalize_focus() from snacks and native providers - Update both providers to use utils.normalize_focus() - Fix queue_timeout inconsistency: update tests to use 5000ms (matching config default) - Maintain backward compatibility and functionality This addresses Copilot feedback about duplicated code and config inconsistencies. Change-Id: I6eb1496e680fb9c6d940d1b8baa62ab2eda9290f Signed-off-by: Thomas Kosiewski --- lua/claudecode/terminal/native.lua | 12 +++--------- lua/claudecode/terminal/snacks.lua | 12 +++--------- lua/claudecode/utils.lua | 13 +++++++++++++ tests/config_test.lua | 2 +- tests/unit/config_spec.lua | 2 +- 5 files changed, 21 insertions(+), 20 deletions(-) create mode 100644 lua/claudecode/utils.lua diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 9b3160d..8c73c97 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -5,13 +5,7 @@ local M = {} local logger = require("claudecode.logger") - ---- Normalizes focus parameter to default to true for backward compatibility ---- @param focus boolean|nil The focus parameter ---- @return boolean Normalized focus value -local function normalize_focus(focus) - return focus == nil and true or focus -end +local utils = require("claudecode.utils") local bufnr = nil local winid = nil @@ -53,7 +47,7 @@ local function is_valid() end local function open_terminal(cmd_string, env_table, effective_config, focus) - focus = normalize_focus(focus) + focus = utils.normalize_focus(focus) if is_valid() then -- Should not happen if called correctly, but as a safeguard if focus then @@ -269,7 +263,7 @@ end --- @param effective_config table --- @param focus boolean|nil function M.open(cmd_string, env_table, effective_config, focus) - focus = normalize_focus(focus) + focus = utils.normalize_focus(focus) if is_valid() then if focus then diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 0be6a10..63c1f6b 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -5,6 +5,7 @@ local M = {} local snacks_available, Snacks = pcall(require, "snacks") +local utils = require("claudecode.utils") local terminal = nil --- @return boolean @@ -41,19 +42,12 @@ local function setup_terminal_events(term_instance, config) end, { buf = true }) end ---- Normalizes focus parameter to default to true for backward compatibility ---- @param focus boolean|nil The focus parameter ---- @return boolean Normalized focus value -local function normalize_focus(focus) - return focus == nil and true or focus -end - --- @param config table --- @param env_table table --- @param focus boolean|nil --- @return table local function build_opts(config, env_table, focus) - focus = normalize_focus(focus) + focus = utils.normalize_focus(focus) return { env = env_table, start_insert = focus, @@ -82,7 +76,7 @@ function M.open(cmd_string, env_table, config, focus) return end - focus = normalize_focus(focus) + focus = utils.normalize_focus(focus) if terminal and terminal:buf_valid() then if focus then diff --git a/lua/claudecode/utils.lua b/lua/claudecode/utils.lua new file mode 100644 index 0000000..b2d9f0f --- /dev/null +++ b/lua/claudecode/utils.lua @@ -0,0 +1,13 @@ +--- Shared utility functions for claudecode.nvim +-- @module claudecode.utils + +local M = {} + +--- Normalizes focus parameter to default to true for backward compatibility +--- @param focus boolean|nil The focus parameter +--- @return boolean Normalized focus value +function M.normalize_focus(focus) + return focus == nil and true or focus +end + +return M diff --git a/tests/config_test.lua b/tests/config_test.lua index 7d1d095..9b4aaec 100644 --- a/tests/config_test.lua +++ b/tests/config_test.lua @@ -185,7 +185,7 @@ describe("Config module", function() visual_demotion_delay_ms = 50, connection_wait_delay = 200, connection_timeout = 10000, - queue_timeout = 3000, + queue_timeout = 5000, diff_opts = { auto_close_on_accept = true, show_diff_stats = true, diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 5be3e37..0bada03 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -34,7 +34,7 @@ describe("Configuration", function() visual_demotion_delay_ms = 50, connection_wait_delay = 200, connection_timeout = 10000, - queue_timeout = 3000, + queue_timeout = 5000, diff_opts = { auto_close_on_accept = true, show_diff_stats = true, From d668b4950b29ead149edbabf7756a8372fc5c789 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Jun 2025 15:10:50 +0200 Subject: [PATCH 7/8] docs: improve terminal focus documentation and clarify behavior - Update build_opts() documentation to explain focus parameter behavior - Add comprehensive comments explaining window restoration logic in native provider - Clarify that focus=false preserves user context in all terminal scenarios: - Existing terminal: stays in current window - Recovered terminal: stays in current window - New terminal: returns to original window - Improve maintainer understanding of focus control architecture Addresses Copilot feedback about documentation and behavior clarity. Change-Id: I06e785d5e2378b68fce41eb64018067d2754225d Signed-off-by: Thomas Kosiewski --- lua/claudecode/terminal/native.lua | 8 ++++++-- lua/claudecode/terminal/snacks.lua | 9 +++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 8c73c97..95b7087 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -51,9 +51,11 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) if is_valid() then -- Should not happen if called correctly, but as a safeguard if focus then + -- Focus existing terminal: switch to terminal window and enter insert mode vim.api.nvim_set_current_win(winid) vim.cmd("startinsert") end + -- If focus=false, preserve user context by staying in current window return true end @@ -127,10 +129,11 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) -- buftype=terminal is set by termopen if focus then + -- Focus the terminal: switch to terminal window and enter insert mode vim.api.nvim_set_current_win(winid) vim.cmd("startinsert") else - -- Return to original window if not focusing + -- Preserve user context: return to the window they were in before terminal creation vim.api.nvim_set_current_win(original_win) end @@ -279,8 +282,9 @@ function M.open(cmd_string, env_table, effective_config, focus) -- Note: We can't recover the job ID easily, but it's less critical logger.debug("terminal", "Recovered existing Claude terminal") if focus then - focus_terminal() + focus_terminal() -- Focus recovered terminal end + -- If focus=false, preserve user context by staying in current window else if not open_terminal(cmd_string, env_table, effective_config, focus) then vim.notify("Failed to open Claude terminal using native fallback.", vim.log.levels.ERROR) diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 63c1f6b..72acef2 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -42,10 +42,11 @@ local function setup_terminal_events(term_instance, config) end, { buf = true }) end ---- @param config table ---- @param env_table table ---- @param focus boolean|nil ---- @return table +--- Builds Snacks terminal options with focus control +--- @param config table Terminal configuration (split_side, split_width_percentage, etc.) +--- @param env_table table Environment variables to set for the terminal process +--- @param focus boolean|nil Whether to focus the terminal when opened (defaults to true) +--- @return table Snacks terminal options with start_insert/auto_insert controlled by focus parameter local function build_opts(config, env_table, focus) focus = utils.normalize_focus(focus) return { From 193e5f1b9586b1d87774510e0dada746cdc1790c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Jun 2025 16:49:33 +0200 Subject: [PATCH 8/8] fix: resolve hidden terminal visibility issue for @ mentions - Fix snacks provider to detect and show hidden terminals when focus=false - Add hidden terminal logic to native provider using show_hidden_terminal() - Both providers now properly handle the case where terminal buffer exists but has no window - Clean up debug logging after identifying and resolving the issue - Preserve user window context when focus=false in both providers This resolves the issue where @ mentions wouldn't show the terminal when it was hidden in the background. Change-Id: Ibdd884ce9f6b7b0b72d2a04fba611db2b8ff52e7 Signed-off-by: Thomas Kosiewski --- lua/claudecode/terminal/native.lua | 33 ++++++++++++++++++++------- lua/claudecode/terminal/snacks.lua | 36 ++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 95b7087..d5c4a33 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -196,7 +196,7 @@ local function hide_terminal() end end -local function show_hidden_terminal(effective_config) +local function show_hidden_terminal(effective_config, focus) -- Show an existing hidden terminal buffer in a new window if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return false @@ -204,10 +204,14 @@ local function show_hidden_terminal(effective_config) -- Check if it's already visible if is_terminal_visible() then - focus_terminal() + if focus then + focus_terminal() + end return true end + local original_win = vim.api.nvim_get_current_win() + -- Create a new window for the existing buffer local width = math.floor(vim.o.columns * effective_config.split_width_percentage) local full_height = vim.o.lines @@ -227,8 +231,14 @@ local function show_hidden_terminal(effective_config) vim.api.nvim_win_set_buf(new_winid, bufnr) winid = new_winid - vim.api.nvim_set_current_win(winid) - vim.cmd("startinsert") + if focus then + -- Focus the terminal: switch to terminal window and enter insert mode + vim.api.nvim_set_current_win(winid) + vim.cmd("startinsert") + else + -- Preserve user context: return to the window they were in before showing terminal + vim.api.nvim_set_current_win(original_win) + end logger.debug("terminal", "Showed hidden terminal in new window") return true @@ -269,8 +279,15 @@ function M.open(cmd_string, env_table, effective_config, focus) focus = utils.normalize_focus(focus) if is_valid() then - if focus then - focus_terminal() + -- Check if terminal exists but is hidden (no window) + if not winid or not vim.api.nvim_win_is_valid(winid) then + -- Terminal is hidden, show it by calling show_hidden_terminal + show_hidden_terminal(effective_config, focus) + else + -- Terminal is already visible + if focus then + focus_terminal() + end end else -- Check if there's an existing Claude terminal we lost track of @@ -313,7 +330,7 @@ function M.simple_toggle(cmd_string, env_table, effective_config) -- Terminal is not visible if has_buffer then -- Terminal process exists but is hidden, show it - if show_hidden_terminal(effective_config) then + if show_hidden_terminal(effective_config, true) then logger.debug("terminal", "Showing hidden terminal") else logger.error("terminal", "Failed to show hidden terminal") @@ -360,7 +377,7 @@ function M.focus_toggle(cmd_string, env_table, effective_config) end else -- Terminal process exists but is hidden, show it - if show_hidden_terminal(effective_config) then + if show_hidden_terminal(effective_config, true) then logger.debug("terminal", "Showing hidden terminal") else logger.error("terminal", "Failed to show hidden terminal") diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 72acef2..30c2b46 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -80,15 +80,33 @@ function M.open(cmd_string, env_table, config, focus) focus = utils.normalize_focus(focus) if terminal and terminal:buf_valid() then - if focus then - terminal:focus() - local term_buf_id = terminal.buf - if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then - -- Check if window is valid before calling nvim_win_call - if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then - vim.api.nvim_win_call(terminal.win, function() - vim.cmd("startinsert") - end) + -- Check if terminal exists but is hidden (no window) + if not terminal.win or not vim.api.nvim_win_is_valid(terminal.win) then + -- Terminal is hidden, show it using snacks toggle + terminal:toggle() + if focus then + terminal:focus() + local term_buf_id = terminal.buf + if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then + if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then + vim.api.nvim_win_call(terminal.win, function() + vim.cmd("startinsert") + end) + end + end + end + else + -- Terminal is already visible + if focus then + terminal:focus() + local term_buf_id = terminal.buf + if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then + -- Check if window is valid before calling nvim_win_call + if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then + vim.api.nvim_win_call(terminal.win, function() + vim.cmd("startinsert") + end) + end end end end