diff --git a/README.md b/README.md index b3c31c7..e913894 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,12 @@ When Anthropic released Claude Code, they only supported VS Code and JetBrains. ```lua { "coder/claudecode.nvim", - dependencies = { "folke/snacks.nvim" }, + dependencies = { + -- For Snacks terminal + "folke/snacks.nvim", + -- For ergoterm terminal + "waiting-for-dev/ergoterm.nvim", + }, config = true, keys = { { "a", nil, desc = "AI/Claude Code" }, @@ -155,7 +160,7 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). terminal = { split_side = "right", -- "left" or "right" split_width_percentage = 0.30, - provider = "auto", -- "auto", "snacks", or "native" + provider = "auto", -- "auto", "snacks", "ergoterm", or "native" auto_close = true, }, diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index bcd0f10..be51ba6 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -93,9 +93,9 @@ function M.on_cursor_moved() end --- Handles mode change events. --- Triggers an immediate update of the selection. +-- Triggers a debounced update of the selection. function M.on_mode_changed() - M.update_selection() + M.debounce_update() end --- Handles text change events. diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 896a5da..72e4e9d 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -1,5 +1,5 @@ --- Module to manage a dedicated vertical split terminal for Claude Code. --- Supports Snacks.nvim or a native Neovim terminal fallback. +-- Supports Snacks.nvim, ergoterm.nvim, or a native Neovim terminal fallback. -- @module claudecode.terminal --- @class TerminalProvider @@ -51,11 +51,15 @@ local function get_provider() local logger = require("claudecode.logger") if config.provider == "auto" then - -- Try snacks first, then fallback to native silently + -- Try providers in order: snacks, ergoterm, then fallback to native silently local snacks_provider = load_provider("snacks") if snacks_provider and snacks_provider.is_available() then return snacks_provider end + local ergoterm_provider = load_provider("ergoterm") + if ergoterm_provider and ergoterm_provider.is_available() then + return ergoterm_provider + end -- Fall through to native provider elseif config.provider == "snacks" then local snacks_provider = load_provider("snacks") @@ -64,6 +68,16 @@ local function get_provider() else logger.warn("terminal", "'snacks' provider configured, but Snacks.nvim not available. Falling back to 'native'.") end + elseif config.provider == "ergoterm" then + local ergoterm_provider = load_provider("ergoterm") + if ergoterm_provider and ergoterm_provider.is_available() then + return ergoterm_provider + else + logger.warn( + "terminal", + "'ergoterm' provider configured, but ergoterm.nvim not available. Falling back to 'native'." + ) + end elseif config.provider == "native" then -- noop, will use native provider as default below logger.debug("terminal", "Using native terminal provider") @@ -177,7 +191,7 @@ end -- @param user_term_config table (optional) Configuration options for the terminal. -- @field user_term_config.split_side string 'left' or 'right' (default: 'right'). -- @field user_term_config.split_width_percentage number Percentage of screen width (0.0 to 1.0, default: 0.30). --- @field user_term_config.provider string 'snacks' or 'native' (default: 'snacks'). +-- @field user_term_config.provider string 'snacks', 'ergoterm', 'native', or 'auto' (default: 'auto'). -- @field user_term_config.show_native_term_exit_tip boolean Show tip for exiting native terminal (default: true). -- @param p_terminal_cmd string|nil The command to run in the terminal (from main config). function M.setup(user_term_config, p_terminal_cmd) @@ -204,7 +218,7 @@ function M.setup(user_term_config, p_terminal_cmd) config[k] = v elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then config[k] = v - elseif k == "provider" and (v == "snacks" or v == "native") then + elseif k == "provider" and (v == "snacks" or v == "ergoterm" or v == "native" or v == "auto") then config[k] = v elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then config[k] = v @@ -280,7 +294,7 @@ function M.toggle(opts_override, cmd_args) end --- Gets the buffer number of the currently active Claude Code terminal. --- This checks both Snacks and native fallback terminals. +-- This checks Snacks, ergoterm, and native fallback terminals. -- @return number|nil The buffer number if an active terminal is found, otherwise nil. function M.get_active_terminal_bufnr() return get_provider().get_active_bufnr() diff --git a/lua/claudecode/terminal/ergoterm.lua b/lua/claudecode/terminal/ergoterm.lua new file mode 100644 index 0000000..629e9c8 --- /dev/null +++ b/lua/claudecode/terminal/ergoterm.lua @@ -0,0 +1,215 @@ +--- Ergoterm.nvim terminal provider for Claude Code. +-- @module claudecode.terminal.ergoterm + +--- @type TerminalProvider +local M = {} + +local ergoterm_available, ergoterm = pcall(require, "ergoterm.terminal") +local utils = require("claudecode.utils") +local terminal = nil + +--- @return boolean +local function is_available() + return ergoterm_available and ergoterm and ergoterm.Terminal +end + +--- Setup event handlers for terminal instance +--- @param term_instance table The ergoterm Terminal instance +--- @param config table Configuration options +local function setup_terminal_events(term_instance, config) + local logger = require("claudecode.logger") + + -- Handle command completion/exit - only if auto_close is enabled + if config.auto_close then + -- Note: ergoterm doesn't have direct event handlers like Snacks, + -- so we'll need to monitor the terminal state differently + -- For now, we'll rely on the terminal's built-in cleanup mechanisms + logger.debug("terminal", "Ergoterm terminal created with auto_close enabled") + end +end + +--- Builds ergoterm Terminal options +--- @param config table Terminal configuration (split_side, split_width_percentage, etc.) +--- @param env_table table Environment variables to set for the terminal process +--- @param cmd_string string Command to run in terminal +--- @param focus boolean|nil Whether to focus the terminal when opened (defaults to true) +--- @return table Ergoterm Terminal configuration +local function build_terminal_opts(config, env_table, cmd_string, focus) + focus = utils.normalize_focus(focus) + + -- Convert split_side to ergoterm layout string format + local layout = config.split_side == "left" and "left" or "right" + + return { + name = "claude-code", + cmd = cmd_string, + layout = layout, + start_in_insert = focus, + auto_scroll = true, + selectable = true, + env = env_table, + } +end + +function M.setup() + -- No specific setup needed for ergoterm provider +end + +--- @param cmd_string string +--- @param env_table table +--- @param config table +--- @param focus boolean|nil +function M.open(cmd_string, env_table, config, focus) + if not is_available() then + vim.notify("Ergoterm terminal provider selected but ergoterm.nvim not available.", vim.log.levels.ERROR) + return + end + + focus = utils.normalize_focus(focus) + + if terminal and terminal:is_started() then + -- Terminal exists + if not terminal:is_open() then + -- Terminal is hidden, show it + terminal:open() + if focus then + terminal:focus() + end + else + -- Terminal is already visible + if focus then + terminal:focus() + end + end + return + end + + -- Create new terminal + local opts = build_terminal_opts(config, env_table, cmd_string, focus) + terminal = ergoterm.Terminal:new(opts) + + if terminal then + setup_terminal_events(terminal, config) + -- Start and open the terminal + terminal:start() + terminal:open() + if focus then + terminal:focus() + end + else + local logger = require("claudecode.logger") + local error_msg = string.format("Failed to create ergoterm Terminal with cmd='%s'", cmd_string) + vim.notify(error_msg, vim.log.levels.ERROR) + logger.debug("terminal", error_msg) + end +end + +function M.close() + if not is_available() then + return + end + if terminal and terminal:is_started() then + terminal:close() + end +end + +--- Simple toggle: always show/hide terminal regardless of focus +--- @param cmd_string string +--- @param env_table table +--- @param config table +function M.simple_toggle(cmd_string, env_table, config) + if not is_available() then + vim.notify("Ergoterm terminal provider selected but ergoterm.nvim not available.", vim.log.levels.ERROR) + return + end + + local logger = require("claudecode.logger") + + if terminal and terminal:is_started() then + if terminal:is_open() then + -- Terminal is visible, close it + logger.debug("terminal", "Simple toggle: closing visible terminal") + terminal:close() + else + -- Terminal exists but not visible, show it + logger.debug("terminal", "Simple toggle: showing hidden terminal") + terminal:open() + terminal:focus() + end + else + -- No terminal exists, create new one + logger.debug("terminal", "Simple toggle: creating new terminal") + M.open(cmd_string, env_table, config, true) + end +end + +--- Smart focus toggle: switches to terminal if not focused, hides if currently focused +--- @param cmd_string string +--- @param env_table table +--- @param config table +function M.focus_toggle(cmd_string, env_table, config) + if not is_available() then + vim.notify("Ergoterm terminal provider selected but ergoterm.nvim not available.", vim.log.levels.ERROR) + return + end + + local logger = require("claudecode.logger") + + if terminal and terminal:is_started() then + if terminal:is_open() then + -- Terminal is visible - check if focused + if terminal:is_focused() then + -- Currently focused, hide it + logger.debug("terminal", "Focus toggle: hiding focused terminal") + terminal:close() + else + -- Not focused, focus it + logger.debug("terminal", "Focus toggle: focusing terminal") + terminal:focus() + end + else + -- Terminal exists but not visible, show and focus it + logger.debug("terminal", "Focus toggle: showing and focusing hidden terminal") + terminal:open() + terminal:focus() + end + else + -- No terminal exists, create new one + logger.debug("terminal", "Focus toggle: creating new terminal") + M.open(cmd_string, env_table, config, true) + end +end + +--- Legacy toggle function for backward compatibility (defaults to simple_toggle) +--- @param cmd_string string +--- @param env_table table +--- @param config table +function M.toggle(cmd_string, env_table, config) + M.simple_toggle(cmd_string, env_table, config) +end + +--- @return number|nil +function M.get_active_bufnr() + if terminal and terminal:is_started() then + -- ergoterm doesn't expose buffer directly, but we can try to get it + -- from the terminal's internal state if available + if terminal.buf and vim.api.nvim_buf_is_valid(terminal.buf) then + return terminal.buf + end + end + return nil +end + +--- @return boolean +function M.is_available() + return is_available() +end + +-- For testing purposes +--- @return table|nil +function M._get_terminal_for_test() + return terminal +end + +return M + diff --git a/tests/unit/terminal_spec.lua b/tests/unit/terminal_spec.lua index f0169d1..3147944 100644 --- a/tests/unit/terminal_spec.lua +++ b/tests/unit/terminal_spec.lua @@ -6,6 +6,7 @@ describe("claudecode.terminal (wrapper for Snacks.nvim)", function() local mock_claudecode_config_module local mock_snacks_provider local mock_native_provider + local mock_ergoterm_provider local last_created_mock_term_instance local create_mock_terminal_instance @@ -225,6 +226,7 @@ describe("claudecode.terminal (wrapper for Snacks.nvim)", function() package.loaded["claudecode.terminal"] = nil package.loaded["claudecode.terminal.snacks"] = nil package.loaded["claudecode.terminal.native"] = nil + package.loaded["claudecode.terminal.ergoterm"] = nil package.loaded["claudecode.server.init"] = nil package.loaded["snacks"] = nil package.loaded["claudecode.config"] = nil @@ -288,6 +290,25 @@ describe("claudecode.terminal (wrapper for Snacks.nvim)", function() } package.loaded["claudecode.terminal.native"] = mock_native_provider + mock_ergoterm_provider = { + setup = spy.new(function() end), + open = spy.new(function() end), + close = spy.new(function() end), + toggle = spy.new(function() end), + simple_toggle = spy.new(function() end), + focus_toggle = spy.new(function() end), + get_active_bufnr = spy.new(function() + return nil + end), + is_available = spy.new(function() + return true + end), + _get_terminal_for_test = spy.new(function() + return nil + end), + } + package.loaded["claudecode.terminal.ergoterm"] = mock_ergoterm_provider + mock_snacks_terminal = { open = spy.new(create_mock_terminal_instance), toggle = spy.new(function(cmd, opts) @@ -357,6 +378,7 @@ describe("claudecode.terminal (wrapper for Snacks.nvim)", function() package.loaded["claudecode.terminal"] = nil package.loaded["claudecode.terminal.snacks"] = nil package.loaded["claudecode.terminal.native"] = nil + package.loaded["claudecode.terminal.ergoterm"] = nil package.loaded["claudecode.server.init"] = nil package.loaded["snacks"] = nil package.loaded["claudecode.config"] = nil @@ -700,4 +722,151 @@ describe("claudecode.terminal (wrapper for Snacks.nvim)", function() assert.are.equal("claude", toggle_cmd) end) end) + + describe("ergoterm provider support", function() + describe("provider selection", function() + it("should use ergoterm provider when explicitly configured", function() + terminal_wrapper.setup({ provider = "ergoterm" }) + terminal_wrapper.open() + + mock_ergoterm_provider.open:was_called(1) + mock_snacks_provider.open:was_not_called() + mock_native_provider.open:was_not_called() + end) + + it("should fall back to native when ergoterm is unavailable", function() + mock_ergoterm_provider.is_available = spy.new(function() + return false + end) + + terminal_wrapper.setup({ provider = "ergoterm" }) + terminal_wrapper.open() + + mock_ergoterm_provider.is_available:was_called() + mock_native_provider.open:was_called(1) + mock_ergoterm_provider.open:was_not_called() + end) + + it("should warn when ergoterm provider configured but unavailable", function() + mock_ergoterm_provider.is_available = spy.new(function() + return false + end) + vim.notify:reset() + + terminal_wrapper.setup({ provider = "ergoterm" }) + terminal_wrapper.open() + + -- Logger would be called but vim.notify in test only captures explicit notifications + mock_native_provider.open:was_called(1) + end) + + it("should validate ergoterm as valid provider value in setup", function() + terminal_wrapper.setup({ provider = "ergoterm", split_side = "left" }) + terminal_wrapper.open() + + local config_arg = mock_ergoterm_provider.open:get_call(1).refs[3] + assert.are.equal("left", config_arg.split_side) + vim.notify:was_not_called() -- No validation warnings + end) + + it("should accept auto as valid provider value and detect ergoterm", function() + mock_snacks_provider.is_available = spy.new(function() + return false + end) + + terminal_wrapper.setup({ provider = "auto" }) + terminal_wrapper.open() + + mock_ergoterm_provider.open:was_called(1) + vim.notify:was_not_called() -- No validation warnings + end) + end) + + describe("ergoterm provider methods", function() + before_each(function() + terminal_wrapper.setup({ provider = "ergoterm" }) + end) + + it("should call ergoterm open with correct parameters", function() + terminal_wrapper.open({ split_side = "left", split_width_percentage = 0.4 }, "--resume") + + mock_ergoterm_provider.open:was_called(1) + local cmd_arg = mock_ergoterm_provider.open:get_call(1).refs[1] + local env_arg = mock_ergoterm_provider.open:get_call(1).refs[2] + local config_arg = mock_ergoterm_provider.open:get_call(1).refs[3] + + assert.are.equal("claude --resume", cmd_arg) + assert.is_table(env_arg) + assert.are.equal("true", env_arg.ENABLE_IDE_INTEGRATION) + assert.is_table(config_arg) + assert.are.equal("left", config_arg.split_side) + assert.are.equal(0.4, config_arg.split_width_percentage) + end) + + it("should call ergoterm close", function() + terminal_wrapper.close() + mock_ergoterm_provider.close:was_called(1) + end) + + it("should call ergoterm simple_toggle", function() + terminal_wrapper.simple_toggle({ split_side = "right" }, "--verbose") + + mock_ergoterm_provider.simple_toggle:was_called(1) + local cmd_arg = mock_ergoterm_provider.simple_toggle:get_call(1).refs[1] + local config_arg = mock_ergoterm_provider.simple_toggle:get_call(1).refs[3] + + assert.are.equal("claude --verbose", cmd_arg) + assert.are.equal("right", config_arg.split_side) + end) + + it("should call ergoterm focus_toggle", function() + terminal_wrapper.focus_toggle() + mock_ergoterm_provider.focus_toggle:was_called(1) + end) + + it("should call ergoterm get_active_bufnr", function() + terminal_wrapper.get_active_terminal_bufnr() + mock_ergoterm_provider.get_active_bufnr:was_called(1) + end) + + it("should call legacy toggle method which defaults to simple_toggle", function() + terminal_wrapper.toggle() + mock_ergoterm_provider.simple_toggle:was_called(1) + end) + end) + + describe("provider detection order", function() + it("should try providers in correct order: snacks -> ergoterm -> native", function() + -- Make both snacks and ergoterm unavailable to test the full chain + mock_snacks_provider.is_available = spy.new(function() + return false + end) + mock_ergoterm_provider.is_available = spy.new(function() + return false + end) + + terminal_wrapper.setup({ provider = "auto" }) + terminal_wrapper.open() + + -- Should check snacks first, then ergoterm, then fall back to native + mock_snacks_provider.is_available:was_called() + mock_ergoterm_provider.is_available:was_called() + mock_native_provider.open:was_called(1) + end) + + it("should stop at ergoterm when snacks unavailable but ergoterm available", function() + mock_snacks_provider.is_available = spy.new(function() + return false + end) + -- ergoterm is available (default mock behavior) + + terminal_wrapper.setup({ provider = "auto" }) + terminal_wrapper.open() + + mock_snacks_provider.is_available:was_called() + mock_ergoterm_provider.open:was_called(1) + mock_native_provider.open:was_not_called() + end) + end) + end) end)