diff --git a/README.md b/README.md index 093dd41..cb26aad 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,13 @@ This plugin was built entirely with Claude Code in a Neovim terminal, and then i - ๐Ÿ”„ Automatically detect and reload files modified by Claude Code - โšก Real-time buffer updates when files are changed externally - ๐Ÿ“ฑ Customizable window position and size (including floating windows) +- ๐ŸŽฏ **Visual selection support** - Send selected code to Claude with context - ๐Ÿค– Integration with which-key (if available) - ๐Ÿ“‚ Automatically uses git project root as working directory (when available) - ๐Ÿงฉ Modular and maintainable code structure - ๐Ÿ“‹ Type annotations with LuaCATS for better IDE support - โœ… Configuration validation to prevent errors -- ๐Ÿงช Testing framework for reliability (44 comprehensive tests) +- ๐Ÿงช Testing framework for reliability (60 comprehensive tests) ## Requirements @@ -146,6 +147,10 @@ require("claude-code").setup({ verbose = "cV", -- Normal mode keymap for Claude Code with verbose flag }, }, + -- Visual mode selection keymaps + selection = { + ask = "cs", -- Ask about selection with prompt input + }, window_navigation = true, -- Enable window navigation keymaps () scrolling = true, -- Enable scrolling keymaps () for page up/down } @@ -198,6 +203,10 @@ Variant mode mappings (if configured): - `cC` - Toggle Claude Code with --continue flag - `cV` - Toggle Claude Code with --verbose flag +Visual selection mapping (select code in visual mode, then use): + +- `cs` - Ask Claude about the selection (prompts for input) + Additionally, when in the Claude Code terminal: - `` - Move to the window on the left @@ -211,6 +220,23 @@ Note: After scrolling with `` or ``, you'll need to press the `i` key When Claude Code modifies files that are open in Neovim, they'll be automatically reloaded. +### Lua API + +The plugin exposes a Lua API for programmatic control: + +```lua +local claude = require("claude-code") + +-- Basic controls +claude.toggle() -- Toggle terminal visibility +claude.open() -- Open/show terminal +claude.close() -- Hide terminal (keeps session) + +-- Send text to Claude +claude.send("Hello Claude!") -- Send raw text +claude.send("Fix this bug\r") -- With carriage return to submit +``` + ### Floating Window Example To use Claude Code in a floating window: diff --git a/lua/claude-code/config.lua b/lua/claude-code/config.lua index 07d1a2c..68a6a12 100644 --- a/lua/claude-code/config.lua +++ b/lua/claude-code/config.lua @@ -39,9 +39,14 @@ local M = {} -- @field normal string|boolean Normal mode keymap for toggling Claude Code, false to disable -- @field terminal string|boolean Terminal mode keymap for toggling Claude Code, false to disable +--- ClaudeCodeKeymapsSelection class for visual selection keymap configuration +-- @table ClaudeCodeKeymapsSelection +-- @field ask string|boolean Visual mode keymap to ask about selection with prompt input + --- ClaudeCodeKeymaps class for keymap configuration -- @table ClaudeCodeKeymaps -- @field toggle ClaudeCodeKeymapsToggle Keymaps for toggling Claude Code +-- @field selection ClaudeCodeKeymapsSelection Keymaps for visual selection -- @field window_navigation boolean Enable window navigation keymaps -- @field scrolling boolean Enable scrolling keymaps @@ -60,12 +65,18 @@ local M = {} -- @field pushd_cmd string Command to push directory onto stack (e.g., 'pushd' for bash/zsh) -- @field popd_cmd string Command to pop directory from stack (e.g., 'popd' for bash/zsh) +--- ClaudeCodeTmux class for tmux integration configuration +-- @table ClaudeCodeTmux +-- @field enable boolean Enable tmux integration (check for Claude Code in tmux panes first) +-- @field prefer_tmux boolean When true, prefer tmux pane over nvim terminal if both exist + --- ClaudeCodeConfig class for main configuration -- @table ClaudeCodeConfig -- @field window ClaudeCodeWindow Terminal window settings -- @field refresh ClaudeCodeRefresh File refresh settings -- @field git ClaudeCodeGit Git integration settings -- @field shell ClaudeCodeShell Shell-specific configuration +-- @field tmux ClaudeCodeTmux Tmux integration settings -- @field command string Command used to launch Claude Code -- @field command_variants ClaudeCodeCommandVariants Command variants configuration -- @field keymaps ClaudeCodeKeymaps Keymaps configuration @@ -110,6 +121,11 @@ M.default_config = { pushd_cmd = 'pushd', -- Command to push directory onto stack popd_cmd = 'popd', -- Command to pop directory from stack }, + -- Tmux integration settings + tmux = { + enable = true, -- Enable tmux integration (check for Claude Code in tmux panes first) + prefer_tmux = true, -- When true, prefer tmux pane over nvim terminal if both exist + }, -- Command settings command = 'claude', -- Command used to launch Claude Code -- Command variants @@ -131,6 +147,10 @@ M.default_config = { verbose = 'cV', -- Normal mode keymap for Claude Code with verbose flag }, }, + -- Visual mode selection keymaps + selection = { + ask = 'cs', -- Visual mode keymap to ask about selection with prompt input + }, window_navigation = true, -- Enable window navigation keymaps () scrolling = true, -- Enable scrolling keymaps () for page up/down }, @@ -316,6 +336,26 @@ local function validate_shell_config(shell) return true, nil end +--- Validate tmux configuration +--- @param tmux table Tmux configuration +--- @return boolean valid +--- @return string? error_message +local function validate_tmux_config(tmux) + if type(tmux) ~= 'table' then + return false, 'tmux config must be a table' + end + + if type(tmux.enable) ~= 'boolean' then + return false, 'tmux.enable must be a boolean' + end + + if type(tmux.prefer_tmux) ~= 'boolean' then + return false, 'tmux.prefer_tmux must be a boolean' + end + + return true, nil +end + --- Validate keymaps configuration --- @param keymaps table Keymaps configuration --- @return boolean valid @@ -359,6 +399,19 @@ local function validate_keymaps_config(keymaps) return false, 'keymaps.scrolling must be a boolean' end + -- Validate selection keymaps if they exist + if keymaps.selection then + if type(keymaps.selection) ~= 'table' then + return false, 'keymaps.selection must be a table' + end + + if keymaps.selection.ask ~= nil then + if not (keymaps.selection.ask == false or type(keymaps.selection.ask) == 'string') then + return false, 'keymaps.selection.ask must be a string or false' + end + end + end + return true, nil end @@ -418,6 +471,12 @@ local function validate_config(config) return false, err end + -- Validate tmux settings + valid, err = validate_tmux_config(config.tmux) + if not valid then + return false, err + end + -- Validate command settings if type(config.command) ~= 'string' then return false, 'command must be a string' diff --git a/lua/claude-code/init.lua b/lua/claude-code/init.lua index 56dee80..14f52e0 100644 --- a/lua/claude-code/init.lua +++ b/lua/claude-code/init.lua @@ -24,11 +24,13 @@ local file_refresh = require('claude-code.file_refresh') local terminal = require('claude-code.terminal') local git = require('claude-code.git') local version = require('claude-code.version') +local tmux = require('claude-code.tmux') local M = {} -- Make imported modules available M.commands = commands +M.tmux = tmux -- Store the current configuration --- @type table @@ -103,6 +105,108 @@ end --- Version information M.version = version +--- Send raw text to the Claude Code terminal or tmux pane +--- @param text string Text to send to the terminal +--- @return boolean success True if text was sent successfully +function M.send(text) + -- Check tmux first if enabled + if M.config.tmux and M.config.tmux.enable then + local tmux_pane = tmux.find_claude_pane() + if tmux_pane then + -- If prefer_tmux is true, or nvim terminal is not running, use tmux + if M.config.tmux.prefer_tmux or not M.claude_code.current_instance then + return tmux.send_text(tmux_pane, text) + end + end + end + + -- Fall back to nvim terminal + -- Ensure Claude Code is running + if not M.claude_code.current_instance then + -- Start Claude Code first + M.toggle() + -- Wait a bit for terminal to initialize + vim.defer_fn(function() + terminal.send_text(M, text) + end, 100) + return true + end + + return terminal.send_text(M, text) +end + +--- Send raw text followed by Enter to the Claude Code terminal or tmux pane +--- @param text string Text to send +--- @return boolean success True if text was sent successfully +function M.send_with_enter(text) + -- Check tmux first if enabled + if M.config.tmux and M.config.tmux.enable then + local tmux_pane = tmux.find_claude_pane() + if tmux_pane then + if M.config.tmux.prefer_tmux or not M.claude_code.current_instance then + return tmux.send_text_with_enter(tmux_pane, text) + end + end + end + + -- Fall back to nvim terminal + if not M.send(text) then + return false + end + return M.send('\r') +end + +--- Check if Claude Code is available (either in tmux or nvim terminal) +--- @return boolean available True if Claude Code is available +--- @return string source "tmux" or "nvim" indicating where Claude Code is running +function M.is_available() + -- Check tmux first if enabled + if M.config.tmux and M.config.tmux.enable then + if tmux.find_claude_pane() then + return true, 'tmux' + end + end + + -- Check nvim terminal + if M.claude_code.current_instance then + local bufnr = M.claude_code.instances[M.claude_code.current_instance] + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + return true, 'nvim' + end + end + + return false, nil +end + +--- Open Claude Code and focus the terminal window +--- @return boolean success True if window is now visible +function M.open() + if not M.claude_code.current_instance then + M.toggle() + return true + end + + return terminal.ensure_visible(M, M.config) +end + +--- Close the Claude Code terminal window (hide, not terminate) +function M.close() + local instance_id = M.claude_code.current_instance + if not instance_id then + return + end + + local bufnr = M.claude_code.instances[instance_id] + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local win_ids = vim.fn.win_findbuf(bufnr) + for _, win_id in ipairs(win_ids) do + vim.api.nvim_win_close(win_id, true) + end +end + --- Setup function for the plugin --- @param user_config? table User configuration table (optional) function M.setup(user_config) diff --git a/lua/claude-code/keymaps.lua b/lua/claude-code/keymaps.lua index 5441bd1..f4d8155 100644 --- a/lua/claude-code/keymaps.lua +++ b/lua/claude-code/keymaps.lua @@ -52,6 +52,141 @@ function M.register_keymaps(claude_code, config) end end + -- Visual mode keymaps for selection + if config.keymaps.selection and config.keymaps.selection.ask then + --- Wait for Claude Code CLI to be ready by monitoring terminal buffer content + --- @param bufnr number Terminal buffer number + --- @param callback function Function to call when CLI is ready + --- @param is_new_session boolean Whether this is a new session + local function wait_for_cli_ready(bufnr, callback, is_new_session) + -- For existing sessions, CLI is already ready - execute immediately + if not is_new_session then + callback() + return + end + + local max_attempts = 50 -- 50 * 50ms = 2.5 seconds max + local attempts = 0 + + local function check_ready() + attempts = attempts + 1 + + if not vim.api.nvim_buf_is_valid(bufnr) then + if attempts < max_attempts then + vim.defer_fn(check_ready, 50) + else + vim.notify('Claude Code: Terminal buffer became invalid', vim.log.levels.ERROR) + end + return + end + + -- Get terminal buffer content (last few lines where prompt would appear) + local line_count = vim.api.nvim_buf_line_count(bufnr) + local start_line = math.max(0, line_count - 5) + local lines = vim.api.nvim_buf_get_lines(bufnr, start_line, line_count, false) + local content = table.concat(lines, '\n') + + -- Claude Code CLI shows ">" prompt when ready for input + if content:match('>%s*$') then + callback() + return + end + + if attempts < max_attempts then + vim.defer_fn(check_ready, 50) + else + -- Fallback: send anyway after timeout + callback() + end + end + + -- Start checking immediately + check_ready() + end + + vim.keymap.set('x', config.keymaps.selection.ask, function() + -- Capture file info BEFORE vim.ui.input (which exits visual mode) + local filepath = vim.fn.expand('%:p') + + -- Check for unsaved buffer + if filepath == '' then + vim.notify('Claude Code: Cannot send selection from unsaved buffer', vim.log.levels.WARN) + return + end + + -- Use visual mode marks (current selection) + local start_line = vim.fn.line('v') + local end_line = vim.fn.line('.') + -- Ensure start <= end + if start_line > end_line then + start_line, end_line = end_line, start_line + end + + vim.ui.input({ prompt = 'Ask Claude: ' }, function(input) + if input and input ~= '' then + -- Just send file path and line range - Claude Code can read the file + local message = string.format( + 'See %s:%d-%d\n\n%s', + filepath, + start_line, + end_line, + input + ) + local claude = require('claude-code') + local tmux_mod = require('claude-code.tmux') + + -- Check if we should use tmux + local use_tmux = false + local tmux_pane = nil + if claude.config.tmux and claude.config.tmux.enable then + tmux_pane = tmux_mod.find_claude_pane() + if tmux_pane then + if claude.config.tmux.prefer_tmux or not claude.claude_code.current_instance then + use_tmux = true + end + end + end + + if use_tmux and tmux_pane then + -- Send directly to tmux pane + local sent = tmux_mod.send_text(tmux_pane, message) + if sent then + vim.defer_fn(function() + tmux_mod.send_text(tmux_pane, '\r') + end, 50) + end + else + -- Use nvim terminal flow + -- Check if Claude Code is already running + local is_new_session = not claude.claude_code.current_instance + or not claude.claude_code.instances[claude.claude_code.current_instance] + + claude.open() + + -- Get buffer number after open + local instance_id = claude.claude_code.current_instance + local bufnr = instance_id and claude.claude_code.instances[instance_id] + + if not bufnr then + vim.notify('Claude Code: Failed to get terminal buffer', vim.log.levels.ERROR) + return + end + + -- Wait for CLI to be ready, then send message + wait_for_cli_ready(bufnr, function() + local sent = claude.send(message) + if sent then + vim.defer_fn(function() + claude.send('\r') + end, 50) + end + end, is_new_session) + end + end + end) + end, { noremap = true, silent = true, desc = 'Claude Code: Ask about selection' }) + end + -- Register with which-key if it's available vim.defer_fn(function() local status_ok, which_key = pcall(require, 'which-key') @@ -81,6 +216,14 @@ function M.register_keymaps(claude_code, config) end end end + + -- Register selection keymaps with which-key + if config.keymaps.selection and config.keymaps.selection.ask then + which_key.add { + mode = 'x', + { config.keymaps.selection.ask, desc = 'Claude Code: Ask about selection', icon = '๐Ÿค–' }, + } + end end end, 100) end diff --git a/lua/claude-code/terminal.lua b/lua/claude-code/terminal.lua index e2fd643..4d8d696 100644 --- a/lua/claude-code/terminal.lua +++ b/lua/claude-code/terminal.lua @@ -380,6 +380,70 @@ local function create_new_instance(claude_code, config, git, instance_id) end end +--- Get terminal job ID for current instance +--- @param claude_code table The main plugin module +--- @return number|nil job_id Terminal job ID or nil if not found +function M.get_job_id(claude_code) + local instance_id = claude_code.claude_code.current_instance + if not instance_id then + return nil + end + + local bufnr = claude_code.claude_code.instances[instance_id] + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return nil + end + + return vim.b[bufnr].terminal_job_id +end + +--- Send text to the Claude Code terminal +--- @param claude_code table The main plugin module +--- @param text string Text to send to the terminal +--- @return boolean success True if text was sent successfully +function M.send_text(claude_code, text) + local job_id = M.get_job_id(claude_code) + if not job_id then + vim.notify('Claude Code terminal is not running', vim.log.levels.WARN) + return false + end + + -- Send text to terminal + vim.api.nvim_chan_send(job_id, text) + return true +end + +--- Ensure Claude Code window is visible +--- @param claude_code table The main plugin module +--- @param config table Plugin configuration +--- @return boolean success True if window is now visible +function M.ensure_visible(claude_code, config) + local instance_id = claude_code.claude_code.current_instance + if not instance_id then + return false + end + + local bufnr = claude_code.claude_code.instances[instance_id] + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return false + end + + -- Check if already visible + local win_ids = vim.fn.win_findbuf(bufnr) + if #win_ids > 0 then + return true + end + + -- Open the window + if config.window.position == 'float' then + create_float(config, bufnr) + else + create_split(config.window.position, config, bufnr) + end + + return true +end + --- Toggle the Claude Code terminal window --- @param claude_code table The main plugin module --- @param config table The plugin configuration diff --git a/lua/claude-code/tmux.lua b/lua/claude-code/tmux.lua new file mode 100644 index 0000000..531d169 --- /dev/null +++ b/lua/claude-code/tmux.lua @@ -0,0 +1,169 @@ +---@mod claude-code.tmux Tmux integration for claude-code.nvim +---@brief [[ +--- This module provides tmux integration for claude-code.nvim. +--- It allows sending text to Claude Code running in a separate tmux pane/window. +---@brief ]] + +local M = {} + +--- Check if we're running inside tmux +--- @return boolean is_tmux True if running inside tmux +function M.is_in_tmux() + return vim.env.TMUX ~= nil and vim.env.TMUX ~= '' +end + +--- Get child PIDs of a process using ps (more reliable on macOS than pgrep -P) +--- @param pid string Parent process ID +--- @return table children List of child PIDs +local function get_child_pids(pid) + local children = {} + -- Use ps to find all processes and filter by PPID + local result = vim.fn.system(string.format("ps -eo pid,ppid,comm 2>/dev/null | awk '$2 == %s {print $1}'", pid)) + for child_pid in result:gmatch('%d+') do + table.insert(children, child_pid) + end + return children +end + +--- Check if a process or its descendants include claude +--- @param pid string Process ID to check +--- @return boolean found True if claude is found +local function has_claude_descendant(pid) + -- Check direct children first + local children = get_child_pids(pid) + if #children == 0 then + return false + end + + for _, child_pid in ipairs(children) do + -- Check if this child is claude + local proc_name = vim.fn.system(string.format("ps -p %s -o comm= 2>/dev/null", child_pid)) + proc_name = vim.trim(proc_name) + if proc_name == 'claude' then + return true + end + + -- Check grandchildren + local grandchildren = get_child_pids(child_pid) + for _, gc_pid in ipairs(grandchildren) do + local gc_name = vim.fn.system(string.format("ps -p %s -o comm= 2>/dev/null", gc_pid)) + gc_name = vim.trim(gc_name) + if gc_name == 'claude' then + return true + end + end + end + + return false +end + +--- Find a tmux pane running Claude Code +--- @return string|nil pane_id The pane ID if found, nil otherwise +function M.find_claude_pane() + if not M.is_in_tmux() then + return nil + end + + -- Get current pane to exclude it + local current_pane = M.get_current_pane() + + -- First, try to find claude in the same session (not just window) + -- Using -s flag to search current session only + local result = vim.fn.system("tmux list-panes -s -F '#{pane_id}:#{pane_pid}'") + if vim.v.shell_error ~= 0 then + return nil + end + + for line in result:gmatch('[^\r\n]+') do + local pane_id, pid = line:match('^(%%?%d+):(%d+)') + if pane_id and pid and pane_id ~= current_pane then + if has_claude_descendant(pid) then + return pane_id + end + end + end + + -- Fallback: search all panes across all sessions + result = vim.fn.system("tmux list-panes -a -F '#{pane_id}:#{pane_pid}'") + if vim.v.shell_error ~= 0 then + return nil + end + + for line in result:gmatch('[^\r\n]+') do + local pane_id, pid = line:match('^(%%?%d+):(%d+)') + if pane_id and pid and pane_id ~= current_pane then + if has_claude_descendant(pid) then + return pane_id + end + end + end + + return nil +end + +--- Send text to a tmux pane +--- @param pane_id string The target pane ID +--- @param text string The text to send +--- @return boolean success True if send was successful +function M.send_text(pane_id, text) + if not pane_id then + return false + end + + -- Use send-keys with literal flag to handle special characters + -- We escape the text and send it + local escaped_text = vim.fn.shellescape(text) + local cmd = string.format("tmux send-keys -t %s -l %s", pane_id, escaped_text) + vim.fn.system(cmd) + + return vim.v.shell_error == 0 +end + +--- Send text followed by Enter to a tmux pane +--- @param pane_id string The target pane ID +--- @param text string The text to send +--- @return boolean success True if send was successful +function M.send_text_with_enter(pane_id, text) + if not M.send_text(pane_id, text) then + return false + end + + -- Send Enter key + vim.fn.system(string.format("tmux send-keys -t %s Enter", pane_id)) + return vim.v.shell_error == 0 +end + +--- Focus the tmux pane containing Claude Code +--- @param pane_id string The target pane ID +--- @return boolean success True if focus was successful +function M.focus_pane(pane_id) + if not pane_id then + return false + end + + vim.fn.system(string.format("tmux select-pane -t %s", pane_id)) + return vim.v.shell_error == 0 +end + +--- Get the current tmux pane ID (the one running nvim) +--- @return string|nil pane_id The current pane ID +function M.get_current_pane() + if not M.is_in_tmux() then + return nil + end + + local result = vim.fn.system("tmux display-message -p '#{pane_id}'") + if vim.v.shell_error ~= 0 then + return nil + end + + return vim.trim(result) +end + +--- Check if Claude Code is available in tmux (cached check) +--- @return boolean available True if Claude Code pane exists in tmux +function M.is_claude_available() + return M.find_claude_pane() ~= nil +end + +return M diff --git a/tests/spec/selection_spec.lua b/tests/spec/selection_spec.lua new file mode 100644 index 0000000..5dd4287 --- /dev/null +++ b/tests/spec/selection_spec.lua @@ -0,0 +1,417 @@ +-- Tests for visual selection functionality in Claude Code +local assert = require('luassert') +local describe = require('plenary.busted').describe +local it = require('plenary.busted').it + +describe('selection module', function() + local claude_code + local terminal + local sent_text = nil + local chan_send_calls = {} + + before_each(function() + -- Reset tracking variables + sent_text = nil + chan_send_calls = {} + + -- Mock vim functions + _G.vim = _G.vim or {} + _G.vim.api = _G.vim.api or {} + _G.vim.fn = _G.vim.fn or {} + _G.vim.bo = _G.vim.bo or { filetype = 'lua' } + _G.vim.o = { lines = 100, columns = 100, cmdheight = 1 } + _G.vim.log = { levels = { WARN = 2, INFO = 1, ERROR = 3 } } + + -- Mock vim.notify + _G.vim.notify = function(msg, level) + -- Just capture notifications for testing + end + + -- Mock vim.defer_fn to execute immediately in tests + _G.vim.defer_fn = function(fn, delay) + fn() + end + + -- Mock vim.schedule + _G.vim.schedule = function(fn) + fn() + end + + -- Mock vim.cmd + _G.vim.cmd = function(cmd) + return true + end + + -- Mock vim.api.nvim_chan_send to track sent text + _G.vim.api.nvim_chan_send = function(job_id, text) + table.insert(chan_send_calls, { job_id = job_id, text = text }) + sent_text = text + return true + end + + -- Mock vim.api.nvim_buf_is_valid + _G.vim.api.nvim_buf_is_valid = function(bufnr) + return bufnr ~= nil and bufnr > 0 + end + + -- Mock vim.api.nvim_get_option_value + _G.vim.api.nvim_get_option_value = function(option, opts) + if option == 'buftype' then + return 'terminal' + end + return '' + end + + -- Mock vim.b for buffer variables + _G.vim.b = setmetatable({}, { + __index = function(t, bufnr) + if not rawget(t, bufnr) then + rawset(t, bufnr, { + terminal_job_id = 12345 + }) + end + return rawget(t, bufnr) + end + }) + + -- Mock vim.fn.getreg and vim.fn.setreg + local registers = { v = '' } + _G.vim.fn.getreg = function(reg) + return registers[reg] or '' + end + _G.vim.fn.setreg = function(reg, value, regtype) + registers[reg] = value + end + _G.vim.fn.getregtype = function(reg) + return 'v' + end + + -- Mock vim.fn.expand + _G.vim.fn.expand = function(pattern) + if pattern == '%:t' then + return 'test.lua' + elseif pattern == '%:p' then + return '/test/path/test.lua' + end + return '' + end + + -- Mock vim.fn.line + _G.vim.fn.line = function(pattern) + if pattern == "'<" then + return 10 + elseif pattern == "'>" then + return 20 + end + return 1 + end + + -- Mock vim.fn.win_findbuf + _G.vim.fn.win_findbuf = function(bufnr) + return { 1 } -- Return a window ID + end + + -- Mock vim.fn.jobwait + _G.vim.fn.jobwait = function(job_ids, timeout) + return { -1 } -- -1 means job is still running + end + + -- Load terminal module + terminal = require('claude-code.terminal') + + -- Setup claude_code mock + claude_code = { + claude_code = { + instances = { ['global'] = 42 }, + current_instance = 'global', + saved_updatetime = nil, + }, + config = { + window = { + position = 'botright', + }, + }, + } + end) + + describe('terminal.get_job_id', function() + it('should return job_id when terminal is running', function() + local job_id = terminal.get_job_id(claude_code) + assert.are.equal(12345, job_id) + end) + + it('should return nil when no current instance', function() + claude_code.claude_code.current_instance = nil + local job_id = terminal.get_job_id(claude_code) + assert.is_nil(job_id) + end) + + it('should return nil when buffer is invalid', function() + claude_code.claude_code.instances['global'] = -1 + _G.vim.api.nvim_buf_is_valid = function(bufnr) + return false + end + local job_id = terminal.get_job_id(claude_code) + assert.is_nil(job_id) + end) + end) + + describe('terminal.send_text', function() + it('should send text to terminal', function() + local result = terminal.send_text(claude_code, 'hello world') + assert.is_true(result) + assert.are.equal('hello world', sent_text) + end) + + it('should return false when terminal is not running', function() + claude_code.claude_code.current_instance = nil + local result = terminal.send_text(claude_code, 'hello world') + assert.is_false(result) + end) + + it('should use correct job_id', function() + terminal.send_text(claude_code, 'test message') + assert.are.equal(1, #chan_send_calls) + assert.are.equal(12345, chan_send_calls[1].job_id) + assert.are.equal('test message', chan_send_calls[1].text) + end) + end) + + describe('terminal.ensure_visible', function() + it('should return true when window is already visible', function() + local result = terminal.ensure_visible(claude_code, claude_code.config) + assert.is_true(result) + end) + + it('should return false when no current instance', function() + claude_code.claude_code.current_instance = nil + local result = terminal.ensure_visible(claude_code, claude_code.config) + assert.is_false(result) + end) + + it('should return false when buffer is invalid', function() + _G.vim.api.nvim_buf_is_valid = function(bufnr) + return false + end + local result = terminal.ensure_visible(claude_code, claude_code.config) + assert.is_false(result) + end) + end) +end) + +describe('init module selection functions', function() + local init + local sent_texts = {} + + before_each(function() + -- Reset tracking + sent_texts = {} + + -- Setup vim mocks (same as above) + _G.vim = _G.vim or {} + _G.vim.api = _G.vim.api or {} + _G.vim.fn = _G.vim.fn or {} + _G.vim.bo = { filetype = 'lua' } + _G.vim.o = { lines = 100, columns = 100, cmdheight = 1, autoread = true } + _G.vim.log = { levels = { WARN = 2, INFO = 1, ERROR = 3 } } + + _G.vim.notify = function(msg, level) end + _G.vim.defer_fn = function(fn, delay) fn() end + _G.vim.schedule = function(fn) fn() end + _G.vim.cmd = function(cmd) return true end + + _G.vim.api.nvim_chan_send = function(job_id, text) + table.insert(sent_texts, text) + return true + end + + _G.vim.api.nvim_buf_is_valid = function(bufnr) + return bufnr ~= nil and bufnr > 0 + end + + _G.vim.api.nvim_get_option_value = function(option, opts) + if option == 'buftype' then + return 'terminal' + end + return '' + end + + _G.vim.api.nvim_win_close = function(win_id, force) + return true + end + + _G.vim.b = setmetatable({}, { + __index = function(t, bufnr) + if not rawget(t, bufnr) then + rawset(t, bufnr, { terminal_job_id = 12345 }) + end + return rawget(t, bufnr) + end + }) + + -- Mock registers with selection content + local registers = { v = 'selected code here' } + _G.vim.fn.getreg = function(reg) + return registers[reg] or '' + end + _G.vim.fn.setreg = function(reg, value, regtype) + registers[reg] = value + end + _G.vim.fn.getregtype = function(reg) + return 'v' + end + + _G.vim.fn.expand = function(pattern) + if pattern == '%:t' then + return 'test.lua' + elseif pattern == '%:p' then + return '/test/path/test.lua' + end + return '' + end + + _G.vim.fn.line = function(pattern) + if pattern == "'<" then + return 10 + elseif pattern == "'>" then + return 20 + end + return 1 + end + + _G.vim.fn.win_findbuf = function(bufnr) + return { 1 } + end + + _G.vim.fn.jobwait = function(job_ids, timeout) + return { -1 } + end + + _G.vim.fn.bufnr = function(pattern) + return 42 + end + + _G.vim.fn.getcwd = function() + return '/test/dir' + end + + _G.vim.fn.shellescape = function(str) + return "'" .. str .. "'" + end + + _G.vim.api.nvim_get_current_win = function() + return 1 + end + + _G.vim.api.nvim_set_option_value = function(option, value, opts) + return true + end + + _G.vim.api.nvim_create_augroup = function(name, opts) + return 1 + end + + _G.vim.api.nvim_create_autocmd = function(events, opts) + return 1 + end + + _G.vim.api.nvim_set_keymap = function(mode, lhs, rhs, opts) + return true + end + + _G.vim.api.nvim_buf_set_keymap = function(buf, mode, lhs, rhs, opts) + return true + end + + _G.vim.keymap = { + set = function(mode, lhs, rhs, opts) + return true + end + } + + _G.vim.tbl_deep_extend = function(behavior, ...) + local result = {} + for _, tbl in ipairs({ ... }) do + if tbl then + for k, v in pairs(tbl) do + if type(v) == 'table' and type(result[k]) == 'table' then + result[k] = _G.vim.tbl_deep_extend(behavior, result[k], v) + else + result[k] = v + end + end + end + end + return result + end + + _G.vim.deepcopy = function(tbl) + if type(tbl) ~= 'table' then + return tbl + end + local copy = {} + for k, v in pairs(tbl) do + copy[k] = _G.vim.deepcopy(v) + end + return copy + end + + -- Clear module cache and reload + package.loaded['claude-code'] = nil + package.loaded['claude-code.init'] = nil + package.loaded['claude-code.terminal'] = nil + package.loaded['claude-code.config'] = nil + package.loaded['claude-code.commands'] = nil + package.loaded['claude-code.keymaps'] = nil + package.loaded['claude-code.file_refresh'] = nil + package.loaded['claude-code.git'] = nil + package.loaded['claude-code.version'] = nil + + -- Load init module + init = require('claude-code') + + -- Setup the plugin + init.setup({ + keymaps = { + toggle = { + normal = false, + terminal = false, + }, + selection = { + ask = false, + }, + window_navigation = false, + scrolling = false, + }, + }) + + -- Manually set up instance for testing + init.claude_code.instances['global'] = 42 + init.claude_code.current_instance = 'global' + end) + + describe('send function', function() + it('should send text to terminal', function() + local result = init.send('hello world') + assert.is_true(result) + assert.are.equal(1, #sent_texts) + assert.are.equal('hello world', sent_texts[1]) + end) + end) + + describe('open function', function() + it('should return true when window is visible', function() + local result = init.open() + assert.is_true(result) + end) + end) + + describe('close function', function() + it('should close window without error', function() + local success = pcall(function() + init.close() + end) + assert.is_true(success) + end) + end) +end)