diff --git a/README.md b/README.md index 968a8fe..72c27a5 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ That's it! For more configuration options, see [Advanced Setup](#advanced-setup) - `:ClaudeCode [arguments]` - Toggle the Claude Code terminal window (simple show/hide behavior) - `:ClaudeCodeFocus [arguments]` - Smart focus/toggle Claude terminal (switches to terminal if not focused, hides if focused) +- `:ClaudeCodeTmux [arguments]` - Open Claude Code in a tmux pane (works regardless of terminal provider setting) - `:ClaudeCode --resume` - Resume a previous Claude conversation - `:ClaudeCode --continue` - Continue Claude conversation - `:ClaudeCodeSend` - Send current visual selection to Claude, or add files from tree explorer @@ -365,9 +366,11 @@ For most users, the default configuration is sufficient: - **`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 + - `"auto"`: Try tmux (if in tmux session), then snacks.nvim, fallback to native - `"snacks"`: Force snacks.nvim (requires folke/snacks.nvim) - `"native"`: Use built-in Neovim terminal + - `"tmux"`: Use tmux panes (requires tmux session) + - `"external"`: Use external terminal (e.g., separate terminal window) - **`show_native_term_exit_tip`**: Show help text for exiting native terminal - **`auto_close`**: Automatically close terminal when commands finish @@ -449,6 +452,81 @@ For most users, the default configuration is sufficient: } ``` +#### External Terminal Configuration + +If you prefer to run Claude Code in an external terminal (e.g., tmux, separate terminal window), configure the plugin to use the external provider and load on startup: + +```lua +{ + "coder/claudecode.nvim", + event = "VeryLazy", -- Load on startup for auto-start behavior + opts = { + terminal = { + provider = "external", -- Don't launch internal terminals + }, + }, + keys = { + { "a", nil, desc = "AI/Claude Code" }, + -- Add any keymaps you want (but they're not required for loading) + { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, + { + "as", + "ClaudeCodeTreeAdd", + desc = "Add file", + ft = { "NvimTree", "neo-tree", "oil" }, + }, + -- Diff management + { "aa", "ClaudeCodeDiffAccept", desc = "Accept diff" }, + { "ad", "ClaudeCodeDiffDeny", desc = "Deny diff" }, + }, +} +``` + +With this configuration: + +- The MCP server starts automatically when Neovim loads +- Run `claude` in your external terminal to connect +- Use `:ClaudeCodeStatus` to check connection status and get guidance + +#### Tmux Integration + +If you work with tmux sessions, claudecode.nvim can create tmux panes automatically: + +```lua +{ + "coder/claudecode.nvim", + keys = { + { "a", nil, desc = "AI/Claude Code" }, + { "ac", "ClaudeCode", desc = "Toggle Claude" }, + { "ct", "ClaudeCodeTmux", desc = "Claude in tmux pane" }, + { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, + { + "as", + "ClaudeCodeTreeAdd", + desc = "Add file", + ft = { "NvimTree", "neo-tree", "oil" }, + }, + -- Diff management + { "aa", "ClaudeCodeDiffAccept", desc = "Accept diff" }, + { "ad", "ClaudeCodeDiffDeny", desc = "Deny diff" }, + }, + opts = { + terminal = { + provider = "tmux", -- Use tmux panes when available + split_side = "right", -- Create panes to the right + split_width_percentage = 0.4, -- 40% of terminal width + }, + }, +} +``` + +With tmux integration: + +- **Auto-detection**: `provider = "auto"` automatically uses tmux when in tmux sessions +- **Manual command**: `:ClaudeCodeTmux` creates tmux panes regardless of provider setting +- **Pane control**: Supports `split_side` ("left"/"right") and `split_width_percentage` +- **Session persistence**: Tmux panes survive across Neovim restarts + #### Custom Claude Installation ```lua @@ -483,6 +561,7 @@ For most users, the default configuration is sufficient: - **Claude not connecting?** Check `:ClaudeCodeStatus` and verify lock file exists in `~/.claude/ide/` - **Need debug logs?** Set `log_level = "debug"` in setup - **Terminal issues?** Try `provider = "native"` if using snacks.nvim +- **Auto-start not working?** If using external terminal provider, ensure you're using `event = "VeryLazy"` instead of `keys = {...}` only, as lazy loading prevents auto-start from running ## License diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index e318f28..9d71cb9 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -84,7 +84,7 @@ M.state = { ---@alias ClaudeCode.TerminalOpts { \ --- split_side?: "left"|"right", \ --- split_width_percentage?: number, \ ---- provider?: "auto"|"snacks"|"native", \ +--- provider?: "auto"|"snacks"|"native"|"external"|"tmux", \ --- show_native_term_exit_tip?: boolean } --- ---@alias ClaudeCode.SetupOpts { \ @@ -163,9 +163,11 @@ function M._process_queued_mentions() return end - -- Ensure terminal is visible when processing queued mentions + -- Ensure terminal is visible when processing queued mentions (unless using external terminal) local terminal = require("claudecode.terminal") - terminal.ensure_visible() + if not terminal.is_external_provider() then + terminal.ensure_visible() + end local success_count = 0 local total_count = #mentions_to_send @@ -256,15 +258,17 @@ function M.send_at_mention(file_path, start_line, end_line, context) -- Check if Claude Code is connected if M.is_claude_connected() then - -- Claude is connected, send immediately and ensure terminal is visible + -- Claude is connected, send immediately and ensure terminal is visible (unless using external terminal) 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() + if not terminal.is_external_provider() then + terminal.ensure_visible() + end end return success, error_msg else - -- Claude not connected, queue the mention and launch terminal + -- Claude not connected, queue the mention and optionally launch terminal local mention_data = { file_path = file_path, start_line = start_line, @@ -274,11 +278,15 @@ function M.send_at_mention(file_path, start_line, end_line, 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) + if terminal.is_external_provider() then + -- Don't launch internal terminal - assume external Claude Code instance exists + logger.debug(context, "Queued @ mention for external Claude Code instance: " .. file_path) + else + -- Launch terminal with Claude Code + terminal.open() + logger.debug(context, "Queued @ mention and launched Claude Code: " .. file_path) + end return true, nil end @@ -449,6 +457,20 @@ function M._create_commands() vim.api.nvim_create_user_command("ClaudeCodeStatus", function() if M.state.server and M.state.port then logger.info("command", "Claude Code integration is running on port " .. tostring(M.state.port)) + + -- Check if using external terminal provider and provide guidance + local terminal_module_ok, terminal_module = pcall(require, "claudecode.terminal") + if terminal_module_ok and terminal_module then + if terminal_module.is_external_provider() then + local connection_count = M.state.server.get_connection_count and M.state.server.get_connection_count() or 0 + if connection_count > 0 then + logger.info("command", "External Claude Code is connected (" .. connection_count .. " connection(s))") + else + logger.info("command", "MCP server ready for external Claude Code connections") + logger.info("command", "Run 'claude --ide' in your terminal to connect to this Neovim instance") + end + end + end else logger.info("command", "Claude Code integration is not running") end @@ -874,6 +896,26 @@ function M._create_commands() end, { desc = "Close the Claude Code terminal window", }) + + vim.api.nvim_create_user_command("ClaudeCodeTmux", function(opts) + local tmux_provider = require("claudecode.terminal.tmux") + if not tmux_provider.is_available() then + logger.error("command", "ClaudeCodeTmux: Not running in tmux session") + return + end + + -- Use the normal terminal flow but force tmux provider by calling it directly + local cmd_args = opts.args and opts.args ~= "" and opts.args or nil + + local effective_config = { split_side = "right", split_width_percentage = 0.5 } + local cmd_string, claude_env_table = terminal.get_claude_command_and_env(cmd_args) + + tmux_provider.setup({}) + tmux_provider.open(cmd_string, claude_env_table, effective_config, true) + end, { + nargs = "*", + desc = "Open Claude Code in new tmux pane (requires tmux session)", + }) else logger.error( "init", diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 896a5da..229390c 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -51,7 +51,13 @@ local function get_provider() local logger = require("claudecode.logger") if config.provider == "auto" then - -- Try snacks first, then fallback to native silently + -- Try tmux first if in tmux session, then snacks, then fallback to native silently + local tmux_provider = load_provider("tmux") + if tmux_provider and tmux_provider.is_available() then + logger.debug("terminal", "Auto-detected tmux session, using tmux provider") + return tmux_provider + end + local snacks_provider = load_provider("snacks") if snacks_provider and snacks_provider.is_available() then return snacks_provider @@ -67,6 +73,22 @@ local function get_provider() elseif config.provider == "native" then -- noop, will use native provider as default below logger.debug("terminal", "Using native terminal provider") + elseif config.provider == "tmux" then + local tmux_provider = load_provider("tmux") + if tmux_provider and tmux_provider.is_available() then + logger.debug("terminal", "Using tmux terminal provider") + return tmux_provider + else + logger.warn("terminal", "'tmux' provider configured, but not in tmux session. Falling back to 'native'.") + end + elseif config.provider == "external" then + local external_provider = load_provider("external") + if external_provider then + logger.debug("terminal", "Using external terminal provider") + return external_provider + else + logger.error("terminal", "Failed to load external terminal provider. Falling back to 'native'.") + end else logger.warn("terminal", "Invalid provider configured: " .. tostring(config.provider) .. ". Defaulting to 'native'.") end @@ -204,7 +226,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 == "native" or v == "external" or v == "tmux") then config[k] = v elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then config[k] = v @@ -286,6 +308,26 @@ function M.get_active_terminal_bufnr() return get_provider().get_active_bufnr() end +--- Checks if the current terminal provider is external. +-- @return boolean True if using external terminal provider, false otherwise. +function M.is_external_provider() + return config.provider == "external" +end + +--- Checks if the current terminal provider is tmux. +-- @return boolean True if using tmux terminal provider, false otherwise. +function M.is_tmux_provider() + return config.provider == "tmux" +end + +--- Gets the claude command and environment variables for external use. +-- @param cmd_args string|nil Optional arguments to append to the command +-- @return string cmd_string The command string +-- @return table env_table The environment variables table +function M.get_claude_command_and_env(cmd_args) + return get_claude_command_and_env(cmd_args) +end + --- Gets the managed terminal instance for testing purposes. -- NOTE: This function is intended for use in tests to inspect internal state. -- The underscore prefix indicates it's not part of the public API for regular use. diff --git a/lua/claudecode/terminal/external.lua b/lua/claudecode/terminal/external.lua new file mode 100644 index 0000000..4967652 --- /dev/null +++ b/lua/claudecode/terminal/external.lua @@ -0,0 +1,81 @@ +--- External terminal provider for Claude Code. +-- This provider does nothing - it assumes Claude Code is running in an external terminal. +-- @module claudecode.terminal.external + +--- @type TerminalProvider +local M = {} + +local logger = require("claudecode.logger") + +--- Configures the external terminal provider (no-op). +-- @param term_config table The terminal configuration (ignored). +function M.setup(term_config) + logger.info( + "terminal", + "External terminal provider configured - Claude Code commands will not launch internal terminals" + ) + logger.debug("terminal", "External provider setup complete - assuming external Claude Code instance will connect") +end + +--- Opens the Claude terminal (no-op for external provider). +-- @param cmd_string string The command to run (ignored). +-- @param env_table table Environment variables (ignored). +-- @param effective_config table Terminal configuration (ignored). +-- @param focus boolean|nil Whether to focus the terminal (ignored). +function M.open(cmd_string, env_table, effective_config, focus) + logger.debug( + "terminal", + "External terminal provider: open() called - no action taken (assuming external Claude Code)" + ) +end + +--- Closes the managed Claude terminal (no-op for external provider). +function M.close() + logger.debug("terminal", "External terminal provider: close() called - no action taken") +end + +--- Simple toggle: show/hide the Claude terminal (no-op for external provider). +-- @param cmd_string string The command to run (ignored). +-- @param env_table table Environment variables (ignored). +-- @param effective_config table Terminal configuration (ignored). +function M.simple_toggle(cmd_string, env_table, effective_config) + logger.debug("terminal", "External terminal provider: simple_toggle() called - no action taken") +end + +--- Smart focus toggle: switches to terminal if not focused, hides if currently focused (no-op for external provider). +-- @param cmd_string string The command to run (ignored). +-- @param env_table table Environment variables (ignored). +-- @param effective_config table Terminal configuration (ignored). +function M.focus_toggle(cmd_string, env_table, effective_config) + logger.debug("terminal", "External terminal provider: focus_toggle() called - no action taken") +end + +--- Toggles the Claude terminal open or closed (no-op for external provider). +-- @param cmd_string string The command to run (ignored). +-- @param env_table table Environment variables (ignored). +-- @param effective_config table Terminal configuration (ignored). +function M.toggle(cmd_string, env_table, effective_config) + logger.debug("terminal", "External terminal provider: toggle() called - no action taken") +end + +--- Gets the buffer number of the currently active Claude Code terminal. +-- For external provider, this always returns nil since there's no managed terminal. +-- @return nil Always returns nil for external provider. +function M.get_active_bufnr() + return nil +end + +--- Checks if the external terminal provider is available. +-- The external provider is always available. +-- @return boolean Always returns true. +function M.is_available() + return true +end + +--- Gets the managed terminal instance for testing purposes (external provider has none). +-- @return nil Always returns nil for external provider. +function M._get_terminal_for_test() + return nil +end + +return M diff --git a/lua/claudecode/terminal/tmux.lua b/lua/claudecode/terminal/tmux.lua new file mode 100644 index 0000000..d016e3d --- /dev/null +++ b/lua/claudecode/terminal/tmux.lua @@ -0,0 +1,198 @@ +--- Tmux terminal provider for Claude Code. +-- This provider creates tmux panes to run Claude Code in external tmux sessions. +-- @module claudecode.terminal.tmux + +--- @type TerminalProvider +local M = {} + +local logger = require("claudecode.logger") + +local active_pane_id = nil + +local function is_in_tmux() + return vim and vim.env and vim.env.TMUX ~= nil +end + +local function get_tmux_pane_width() + local handle = io.popen("tmux display-message -p '#{window_width}'") + if not handle then + return 80 + end + local result = handle:read("*a") + handle:close() + local cleaned = result and result:gsub("%s+", "") or "" + return tonumber(cleaned) or 80 +end + +local function calculate_split_size(percentage) + if not percentage or percentage <= 0 or percentage >= 1 then + return nil + end + + local window_width = get_tmux_pane_width() + return math.floor(window_width * percentage) +end + +local function build_split_command(cmd_string, env_table, effective_config) + local split_cmd = "tmux split-window" + + if effective_config.split_side == "left" then + split_cmd = split_cmd .. " -bh" + else + split_cmd = split_cmd .. " -h" + end + + local split_size = calculate_split_size(effective_config.split_width_percentage) + if split_size then + split_cmd = split_cmd .. " -l " .. split_size + end + + -- Add environment variables + if env_table then + for key, value in pairs(env_table) do + split_cmd = split_cmd .. " -e '" .. key .. "=" .. value .. "'" + end + end + + split_cmd = split_cmd .. " '" .. cmd_string .. "'" + + return split_cmd +end + +local function get_active_pane_id() + if not active_pane_id then + return nil + end + + local handle = io.popen("tmux list-panes -F '#{pane_id}' | grep '" .. active_pane_id .. "'") + if not handle then + return nil + end + + local result = handle:read("*a") + handle:close() + + if result and result:gsub("%s+", "") == active_pane_id then + return active_pane_id + end + + active_pane_id = nil + return nil +end + +local function capture_new_pane_id(split_cmd) + local full_cmd = split_cmd .. " \\; display-message -p '#{pane_id}'" + local handle = io.popen(full_cmd) + if not handle then + return nil + end + + local result = handle:read("*a") + handle:close() + + local pane_id = result:gsub("%s+", ""):match("%%(%d+)") + return pane_id and ("%" .. pane_id) or nil +end + +function M.setup(term_config) + if not is_in_tmux() then + logger.warn("terminal", "Tmux provider configured but not running in tmux session") + return + end + + logger.debug("terminal", "Tmux terminal provider configured") +end + +function M.open(cmd_string, env_table, effective_config, focus) + if not is_in_tmux() then + logger.error("terminal", "Cannot open tmux pane - not in tmux session") + return + end + + if get_active_pane_id() then + logger.debug("terminal", "Claude tmux pane already exists, focusing existing pane") + if focus ~= false then + vim.fn.system("tmux select-pane -t " .. active_pane_id) + end + return + end + + local split_cmd = build_split_command(cmd_string, env_table, effective_config) + logger.debug("terminal", "Opening tmux pane with command: " .. split_cmd) + + local new_pane_id = capture_new_pane_id(split_cmd) + if new_pane_id then + active_pane_id = new_pane_id + logger.debug("terminal", "Created tmux pane with ID: " .. active_pane_id) + + if focus == false then + vim.fn.system("tmux last-pane") + end + else + logger.error("terminal", "Failed to create tmux pane") + end +end + +function M.close() + local pane_id = get_active_pane_id() + if not pane_id then + logger.debug("terminal", "No active Claude tmux pane to close") + return + end + + vim.fn.system("tmux kill-pane -t " .. pane_id) + active_pane_id = nil + logger.debug("terminal", "Closed tmux pane: " .. pane_id) +end + +function M.simple_toggle(cmd_string, env_table, effective_config) + local pane_id = get_active_pane_id() + if pane_id then + M.close() + else + M.open(cmd_string, env_table, effective_config, true) + end +end + +function M.focus_toggle(cmd_string, env_table, effective_config) + local pane_id = get_active_pane_id() + if not pane_id then + M.open(cmd_string, env_table, effective_config, true) + return + end + + local handle = io.popen("tmux display-message -p '#{pane_active}'") + if not handle then + return + end + + local is_active = handle:read("*a"):gsub("%s+", "") == "1" + handle:close() + + if is_active then + M.close() + else + vim.fn.system("tmux select-pane -t " .. pane_id) + end +end + +function M.toggle(cmd_string, env_table, effective_config) + M.simple_toggle(cmd_string, env_table, effective_config) +end + +function M.get_active_bufnr() + return nil +end + +function M.is_available() + return is_in_tmux() +end + +function M._get_terminal_for_test() + return { + pane_id = active_pane_id, + is_in_tmux = is_in_tmux(), + } +end + +return M diff --git a/tests/unit/claudecode_add_command_spec.lua b/tests/unit/claudecode_add_command_spec.lua index 5f98f65..d389f22 100644 --- a/tests/unit/claudecode_add_command_spec.lua +++ b/tests/unit/claudecode_add_command_spec.lua @@ -90,6 +90,12 @@ describe("ClaudeCodeAdd command", function() return 1 end, simple_toggle = spy.new(function() end), + is_external_provider = function() + return false -- Default to false for existing tests + end, + is_tmux_provider = function() + return false -- Default to false for existing tests + 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 e8c8092..e4d43fe 100644 --- a/tests/unit/claudecode_send_command_spec.lua +++ b/tests/unit/claudecode_send_command_spec.lua @@ -72,6 +72,12 @@ describe("ClaudeCodeSend Command Range Functionality", function() mock_terminal = { open = spy.new(function() end), ensure_visible = spy.new(function() end), + is_external_provider = function() + return false -- Default to false for existing tests + end, + is_tmux_provider = function() + return false -- Default to false for existing tests + end, } -- Mock server diff --git a/tests/unit/external_terminal_provider_spec.lua b/tests/unit/external_terminal_provider_spec.lua new file mode 100644 index 0000000..4aa87e3 --- /dev/null +++ b/tests/unit/external_terminal_provider_spec.lua @@ -0,0 +1,86 @@ +-- luacheck: globals expect +require("tests.busted_setup") + +describe("External Terminal Provider", function() + local external_provider + local mock_vim + local logger_debug_spy + + local function setup_mocks() + -- Mock vim global + mock_vim = { + notify = spy.new(function() end), + log = { levels = { WARN = 2, ERROR = 1, INFO = 3, DEBUG = 4 } }, + } + _G.vim = mock_vim + + -- Mock logger with spy + logger_debug_spy = spy.new(function() end) + package.loaded["claudecode.logger"] = { + debug = logger_debug_spy, + warn = function() end, + error = function() end, + info = function() end, + } + end + + before_each(function() + -- Clear module cache + package.loaded["claudecode.terminal.external"] = nil + + setup_mocks() + external_provider = require("claudecode.terminal.external") + end) + + describe("basic functionality", function() + it("should be available", function() + expect(external_provider.is_available()).to_be_true() + end) + + it("should return nil for active buffer", function() + expect(external_provider.get_active_bufnr()).to_be_nil() + end) + + it("should return nil for test terminal", function() + expect(external_provider._get_terminal_for_test()).to_be_nil() + end) + end) + + describe("no-op functions", function() + it("should do nothing on setup", function() + external_provider.setup({ some_config = true }) + -- Should not error and should log debug message + assert.spy(logger_debug_spy).was_called() + end) + + it("should do nothing on open", function() + external_provider.open("claude --ide", { ENV = "test" }, { split_side = "right" }, true) + -- Should not error and should log debug message + assert.spy(logger_debug_spy).was_called() + end) + + it("should do nothing on close", function() + external_provider.close() + -- Should not error and should log debug message + assert.spy(logger_debug_spy).was_called() + end) + + it("should do nothing on simple_toggle", function() + external_provider.simple_toggle("claude", {}, {}) + -- Should not error and should log debug message + assert.spy(logger_debug_spy).was_called() + end) + + it("should do nothing on focus_toggle", function() + external_provider.focus_toggle("claude", {}, {}) + -- Should not error and should log debug message + assert.spy(logger_debug_spy).was_called() + end) + + it("should do nothing on toggle", function() + external_provider.toggle("claude", {}, {}) + -- Should not error and should log debug message + assert.spy(logger_debug_spy).was_called() + end) + end) +end) diff --git a/tests/unit/init_spec.lua b/tests/unit/init_spec.lua index 8840ea0..8911427 100644 --- a/tests/unit/init_spec.lua +++ b/tests/unit/init_spec.lua @@ -298,6 +298,12 @@ describe("claudecode.init", function() close = spy.new(function() end), setup = spy.new(function() end), ensure_visible = spy.new(function() end), + is_external_provider = function() + return false -- Default to false for existing tests + end, + is_tmux_provider = function() + return false -- Default to false for existing tests + end, } local original_require = _G.require diff --git a/tests/unit/tmux_terminal_provider_spec.lua b/tests/unit/tmux_terminal_provider_spec.lua new file mode 100644 index 0000000..6586a89 --- /dev/null +++ b/tests/unit/tmux_terminal_provider_spec.lua @@ -0,0 +1,233 @@ +-- luacheck: globals expect +require("tests.busted_setup") + +describe("Tmux Terminal Provider", function() + local tmux_provider + local mock_vim + local logger_debug_spy, logger_error_spy, logger_warn_spy + + local function setup_mocks() + -- Mock vim global + mock_vim = { + notify = spy.new(function() end), + log = { levels = { WARN = 2, ERROR = 1, INFO = 3, DEBUG = 4 } }, + env = { TMUX = "/private/tmp/tmux-501/default,97181,8" }, + fn = { + system = spy.new(function(cmd) + if cmd:match("tmux display%-message %-p") then + return "100" + elseif cmd:match("tmux list%-panes") then + return "%1" + elseif cmd:match("tmux split%-window.*display%-message") then + return "%2" + end + return "" + end), + }, + } + _G.vim = mock_vim + + -- Mock io.popen + local original_io = _G.io + _G.io = setmetatable({ + popen = spy.new(function(cmd) + local mock_handle = { + read = function(self, format) + if cmd:match("tmux display%-message %-p '#{window_width}'") then + return "100" + elseif cmd:match("tmux list%-panes") then + return "%2" + elseif cmd:match("tmux split%-window.*display%-message") then + return "%2" + elseif cmd:match("tmux display%-message %-p '#{pane_active}'") then + return "0" + end + return "" + end, + close = function() end, + } + return mock_handle + end), + }, { + __index = function(_, key) + return original_io[key] + end, + }) + + -- Mock logger with spies + logger_debug_spy = spy.new(function() end) + logger_error_spy = spy.new(function() end) + logger_warn_spy = spy.new(function() end) + package.loaded["claudecode.logger"] = { + debug = logger_debug_spy, + warn = logger_warn_spy, + error = logger_error_spy, + info = function() end, + } + end + + before_each(function() + -- Clear module cache + package.loaded["claudecode.terminal.tmux"] = nil + + setup_mocks() + tmux_provider = require("claudecode.terminal.tmux") + end) + + describe("availability", function() + it("should be available when in tmux session", function() + expect(tmux_provider.is_available()).to_be_true() + end) + + it("should not be available when not in tmux session", function() + mock_vim.env.TMUX = nil + -- Reload module to pick up new env + package.loaded["claudecode.terminal.tmux"] = nil + tmux_provider = require("claudecode.terminal.tmux") + + expect(tmux_provider.is_available()).to_be_false() + end) + end) + + describe("setup", function() + it("should configure without errors in tmux session", function() + tmux_provider.setup({ split_side = "left" }) + assert.spy(logger_debug_spy).was_called() + end) + + it("should warn when not in tmux session", function() + mock_vim.env.TMUX = nil + package.loaded["claudecode.terminal.tmux"] = nil + tmux_provider = require("claudecode.terminal.tmux") + + tmux_provider.setup({}) + assert.spy(logger_warn_spy).was_called() + end) + end) + + describe("pane management", function() + it("should open new tmux pane with default config", function() + local config = { split_side = "right", split_width_percentage = 0.5 } + tmux_provider.open("claude --ide", {}, config) + + assert.spy(_G.io.popen).was_called() + local call_args = _G.io.popen.calls[#_G.io.popen.calls].refs + local command = call_args[1] + assert.truthy(command:match("tmux split%-window %-h")) + end) + + it("should open pane to the left when split_side is left", function() + local config = { split_side = "left", split_width_percentage = 0.5 } + tmux_provider.open("claude --ide", {}, config) + + assert.spy(_G.io.popen).was_called() + local call_args = _G.io.popen.calls[#_G.io.popen.calls].refs + local command = call_args[1] + assert.truthy(command:match("tmux split%-window %-bh")) + end) + + it("should include size parameter when split_width_percentage is valid", function() + local config = { split_side = "right", split_width_percentage = 0.3 } + tmux_provider.open("claude --ide", {}, config) + + assert.spy(_G.io.popen).was_called() + local call_args = _G.io.popen.calls[#_G.io.popen.calls].refs + local command = call_args[1] + assert.truthy(command:match("%-l 30")) + end) + + it("should not include size parameter when split_width_percentage is invalid", function() + local config = { split_side = "right", split_width_percentage = 1.5 } + tmux_provider.open("claude --ide", {}, config) + + assert.spy(_G.io.popen).was_called() + local call_args = _G.io.popen.calls[#_G.io.popen.calls].refs + local command = call_args[1] + assert.falsy(command:match("%-l")) + end) + + it("should error when trying to open pane outside tmux", function() + mock_vim.env.TMUX = nil + package.loaded["claudecode.terminal.tmux"] = nil + tmux_provider = require("claudecode.terminal.tmux") + + tmux_provider.open("claude --ide", {}, {}) + assert.spy(logger_error_spy).was_called() + end) + + it("should focus existing pane if already open", function() + -- First call creates pane + tmux_provider.open("claude --ide", {}, { split_side = "right" }) + + -- Second call should focus existing + tmux_provider.open("claude --ide", {}, { split_side = "right" }) + + assert.spy(mock_vim.fn.system).was_called() + local system_calls = mock_vim.fn.system.calls + local focus_call = system_calls[#system_calls] + assert.truthy(focus_call.refs[1]:match("tmux select%-pane")) + end) + end) + + describe("toggle operations", function() + it("should open pane on simple_toggle when none exists", function() + tmux_provider.simple_toggle("claude --ide", {}, { split_side = "right" }) + + assert.spy(_G.io.popen).was_called() + local call_args = _G.io.popen.calls[#_G.io.popen.calls].refs + local command = call_args[1] + assert.truthy(command:match("tmux split%-window")) + end) + + it("should close pane on simple_toggle when pane exists", function() + -- First open a pane + tmux_provider.open("claude --ide", {}, { split_side = "right" }) + + -- Then toggle should close it + tmux_provider.simple_toggle("claude --ide", {}, { split_side = "right" }) + + assert.spy(mock_vim.fn.system).was_called() + local system_calls = mock_vim.fn.system.calls + local close_call = system_calls[#system_calls] + assert.truthy(close_call.refs[1]:match("tmux kill%-pane")) + end) + + it("should handle focus_toggle correctly", function() + tmux_provider.focus_toggle("claude --ide", {}, { split_side = "right" }) + + assert.spy(_G.io.popen).was_called() + end) + end) + + describe("state management", function() + it("should return nil for get_active_bufnr", function() + expect(tmux_provider.get_active_bufnr()).to_be_nil() + end) + + it("should provide test interface", function() + local test_info = tmux_provider._get_terminal_for_test() + assert.equal("table", type(test_info)) + assert.is_true(test_info.is_in_tmux) + end) + + it("should clean up state when pane is closed externally", function() + -- Open pane + tmux_provider.open("claude --ide", {}, { split_side = "right" }) + + -- Mock pane no longer existing + _G.io.popen = spy.new(function(cmd) + local mock_handle = { + read = function() + return "" + end, -- Empty result means pane doesn't exist + close = function() end, + } + return mock_handle + end) + + -- Try to close - should handle gracefully + tmux_provider.close() + assert.spy(logger_debug_spy).was_called() + end) + end) +end)