diff --git a/README.md b/README.md index 97da123..3eceda0 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" }, }, }, } @@ -214,10 +214,40 @@ 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, + 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" }, + }, + }, +} +``` + +### Advanced Configuration
-Full configuration with all options +Complete configuration options ```lua { @@ -225,66 +255,184 @@ See [DEVELOPMENT.md](./DEVELOPMENT.md) for build instructions and development gu 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 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 = 5000, -- 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, +} +``` + +
+ +### 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 + +#### Connection Management + +- **`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 Configuration + +- **`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 + +#### 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 +{ + "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" }, + ft = { "NvimTree", "neo-tree", "oil" }, }, - { "ao", "ClaudeCodeOpen", desc = "Open Claude" }, - { "ax", "ClaudeCodeClose", desc = "Close Claude" }, + }, + opts = { + log_level = "warn", -- Reduce log verbosity + auto_start = false, -- Manual startup only }, } ``` - - -### Terminal Auto-Close Behavior - -The `auto_close` option controls what happens when Claude commands finish: - -**When `auto_close = true` (default):** - -- Terminal automatically closes after command completion -- Error notifications shown for failed commands (non-zero exit codes) -- Clean workflow for quick command execution +#### Power User Configuration -**When `auto_close = false`:** +```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 + 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 + }, + }, +} +``` -- 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 +#### Custom Claude Installation ```lua -terminal = { - provider = "snacks", - auto_close = false, -- Keep terminal open to review output +{ + "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/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..525e61e 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" }, @@ -23,7 +22,7 @@ return { "as", "ClaudeCodeTreeAdd", desc = "Add file from tree", - ft = { "NvimTree", "neo-tree" }, + ft = { "NvimTree", "neo-tree", "oil" }, }, -- Development helpers @@ -34,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 = 5000, -- 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 32c1207..9547ef6 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,201 @@ 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 + local terminal = require("claudecode.terminal") + terminal.ensure_visible() + 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 +330,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 +352,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 +360,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 +375,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 +385,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 +396,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 +404,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 +416,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 +448,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 +479,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 +514,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 +536,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 +558,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 +572,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 @@ -443,30 +595,21 @@ 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.") - 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,24 +624,23 @@ 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 + + -- Handle regular text selection using range from visual mode 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 + 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 @@ -520,7 +662,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 @@ -529,10 +671,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 @@ -546,7 +709,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 @@ -555,15 +718,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 @@ -637,7 +815,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 @@ -813,10 +991,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] @@ -833,17 +1007,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 @@ -882,17 +1060,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 @@ -920,17 +1102,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..896a5da 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 @@ -104,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 @@ -138,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. @@ -185,8 +219,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 +257,20 @@ 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) + 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) + 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). -- @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/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index a7f5b22..d5c4a33 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -5,6 +5,7 @@ local M = {} local logger = require("claudecode.logger") +local utils = require("claudecode.utils") local bufnr = nil local winid = nil @@ -45,10 +46,16 @@ 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 = utils.normalize_focus(focus) + 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 + -- 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 @@ -121,8 +128,14 @@ 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 + -- 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 terminal creation + 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) @@ -183,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 @@ -191,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 @@ -214,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 @@ -251,9 +274,21 @@ 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 = utils.normalize_focus(focus) + if is_valid() 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 local existing_buf, existing_win = find_existing_claude_terminal() @@ -263,9 +298,12 @@ 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() -- 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) 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 @@ -292,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") @@ -339,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 fda68ce..30c2b46 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,14 +42,17 @@ local function setup_terminal_events(term_instance, config) end, { buf = true }) end ---- @param config table ---- @param env_table table ---- @return table -local function build_opts(config, env_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 { env = env_table, - start_insert = true, - auto_insert = true, + start_insert = focus, + auto_insert = focus, auto_close = false, win = { position = config.split_side, @@ -66,24 +70,50 @@ 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 = utils.normalize_focus(focus) + 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 - 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 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) 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 4802719..9b4aaec 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 = 5000, 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 93a6188..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 @@ -216,7 +217,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 +229,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() @@ -245,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..0bada03 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 = 5000, 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