diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c34fc600..4a25d443 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -129,22 +129,36 @@ vim.api.nvim_create_autocmd("CursorMoved", { -- Preserves selection context when switching to terminal ``` -### 6. Terminal Integration (`terminal.lua`) +### 6. Terminal Integration (`terminal/`) -Flexible terminal management with provider pattern: +Flexible terminal management with provider pattern and centralized window management: ```lua --- Snacks.nvim provider (preferred) -if has_snacks then - Snacks.terminal.open(cmd, { - win = { position = "right", width = 0.3 } - }) -else - -- Native fallback - vim.cmd("vsplit | terminal " .. cmd) -end +-- Window manager singleton owns THE terminal window +-- Providers only create buffers, window_manager displays them +local window_manager = require("claudecode.terminal.window_manager") + +-- Display a buffer in the managed window (preserves user resizing) +window_manager.display_buffer(bufnr, focus) + +-- Snacks.nvim provider creates buffer, delegates window to manager +local term = Snacks.terminal.open(cmd, opts) +vim.api.nvim_win_close(term.win, false) -- Close snacks' window +window_manager.display_buffer(term.buf, true) -- Use our window + +-- Native provider creates buffer without window +local bufnr = vim.api.nvim_create_buf(false, true) +vim.fn.termopen(cmd, { env = env }) +window_manager.display_buffer(bufnr, true) ``` +Key features: + +- **Single window**: All sessions share one terminal window +- **Buffer switching**: `nvim_win_set_buf()` preserves window size +- **Session management**: Multiple Claude sessions with tab-like switching +- **Window preservation**: User resizing persists across session switches + ## Key Implementation Patterns ### Thread Safety @@ -199,7 +213,15 @@ lua/claudecode/ ├── tools/init.lua # MCP tool registry ├── diff.lua # Native diff support ├── selection.lua # Selection tracking -├── terminal.lua # Terminal management +├── session.lua # Multi-session state management +├── terminal.lua # Terminal orchestration +├── terminal/ # Terminal providers +│ ├── window_manager.lua # Singleton window management +│ ├── snacks.lua # Snacks.nvim provider +│ ├── native.lua # Native Neovim terminal +│ ├── external.lua # External terminal apps +│ ├── tabbar.lua # Session tab bar UI +│ └── osc_handler.lua # Terminal title detection └── lockfile.lua # Discovery files ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index ce6ca5b6..f89bf30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features +- Multi-session terminal support with `:ClaudeCodeNew`, `:ClaudeCodeSessions`, `:ClaudeCodeSwitch`, `:ClaudeCodeCloseSession` commands +- Tab bar UI with mouse support for session management (`terminal.tabs.*` configuration) +- Smart ESC handling - double-tap ESC to exit terminal mode (`esc_timeout`, `keymaps.exit_terminal`) - External terminal provider to run Claude in a separate terminal ([#102](https://github.com/coder/claudecode.nvim/pull/102)) - Terminal provider APIs: implement `ensure_visible` for reliability ([#103](https://github.com/coder/claudecode.nvim/pull/103)) - Working directory control for Claude terminal ([#117](https://github.com/coder/claudecode.nvim/pull/117)) @@ -32,6 +35,10 @@ ### Bug Fixes +- Preserve terminal window size across session operations +- Keep terminal window open when session exits with other sessions available +- Fix cursor position when switching terminal sessions +- Send selection updates on BufEnter event - Wrap ERROR/WARN logging in `vim.schedule` to avoid fast-event context errors ([#54](https://github.com/coder/claudecode.nvim/pull/54)) - Native terminal: do not wipe Claude buffer on window close ([#60](https://github.com/coder/claudecode.nvim/pull/60)) - Native terminal: respect `auto_close` behavior ([#63](https://github.com/coder/claudecode.nvim/pull/63)) diff --git a/README.md b/README.md index e9ec2ff1..011f7319 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # claudecode.nvim -[![Tests](https://github.com/coder/claudecode.nvim/actions/workflows/test.yml/badge.svg)](https://github.com/coder/claudecode.nvim/actions/workflows/test.yml) +[![Tests](https://github.com/snirt/claudecode.nvim/actions/workflows/test.yml/badge.svg)](https://github.com/snirt/claudecode.nvim/actions/workflows/test.yml) ![Neovim version](https://img.shields.io/badge/Neovim-0.8%2B-green) ![Status](https://img.shields.io/badge/Status-beta-blue) @@ -20,11 +20,54 @@ When Anthropic released Claude Code, they only supported VS Code and JetBrains. - ⚡ **First to Market** — Beat Anthropic to releasing Neovim support - 🛠️ **Built with AI** — Used Claude to reverse-engineer Claude's own protocol +## Fork Features + +This fork adds features not available in the original [coder/claudecode.nvim](https://github.com/coder/claudecode.nvim): + +### Multi-Session Support + +Run multiple Claude Code sessions simultaneously, each with isolated state: + +- **Independent sessions** — Each session has its own terminal, WebSocket connection, and context +- **Easy switching** — Use `:ClaudeCodeSessions` for a picker or `:ClaudeCodeSwitch 2` to switch directly +- **Session lifecycle** — Create with `:ClaudeCodeNew`, close with `:ClaudeCodeCloseSession` + +Perfect for working on multiple features, comparing approaches, or keeping separate contexts for different parts of a project. + +### Visual Tab Bar + +A clickable tab bar for managing sessions visually: + +``` +┌─────────────────────────────────────────────────────┐ +│ [1*] ✕ | [2] ✕ | [3] ✕ | [+] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ Claude Code Terminal │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +- **Mouse support** — Click tabs to switch, click ✕ to close, click + for new session +- **Keyboard navigation** — `Alt+Tab` / `Alt+Shift+Tab` to cycle sessions +- **Active indicator** — Current session marked with `*` + +Enable with: + +```lua +terminal = { + tabs = { + enabled = true, + mouse_enabled = true, + }, +} +``` + ## Installation ```lua { - "coder/claudecode.nvim", + "snirt/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, config = true, keys = { @@ -45,6 +88,9 @@ When Anthropic released Claude Code, they only supported VS Code and JetBrains. -- Diff management { "aa", "ClaudeCodeDiffAccept", desc = "Accept diff" }, { "ad", "ClaudeCodeDiffDeny", desc = "Deny diff" }, + -- Multi-session management + { "an", "ClaudeCodeNew", desc = "New Claude session" }, + { "al", "ClaudeCodeSessions", desc = "List Claude sessions" }, }, } ``` @@ -90,7 +136,7 @@ If you have a local installation, configure the plugin with the direct path: ```lua { - "coder/claudecode.nvim", + "snirt/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, opts = { terminal_cmd = "~/.claude/local/claude", -- Point to local installation @@ -147,7 +193,7 @@ Configure the plugin with the detected path: ```lua { - "coder/claudecode.nvim", + "snirt/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, opts = { terminal_cmd = "/path/to/your/claude", -- Use output from 'which claude' @@ -163,6 +209,52 @@ Configure the plugin with the detected path: > **Note**: If Claude Code was installed globally via npm, you can use the default configuration without specifying `terminal_cmd`. +## Recommended Configuration + +A practical configuration with the most useful options: + +```lua +{ + "snirt/claudecode.nvim", + dependencies = { "folke/snacks.nvim" }, + opts = { + -- Terminal as floating window (recommended) + terminal = { + provider = "snacks", + split_side = "right", + split_width_percentage = 0.30, + snacks_win_opts = { + style = "float", + width = 0.8, + height = 0.8, + border = "rounded", + }, + -- Tab bar for multiple sessions + tabs = { + enabled = true, + mouse_enabled = true, + }, + }, + -- Diff behavior + diff_opts = { + auto_close_on_accept = true, + }, + }, + keys = { + { "a", group = "Claude" }, + { "ac", "ClaudeCode", desc = "Toggle Claude" }, + { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, + { "as", "ClaudeCodeSend", mode = "v", desc = "Send selection" }, + { "as", "ClaudeCodeTreeAdd", desc = "Add file", ft = { "NvimTree", "neo-tree", "oil", "minifiles", "netrw" } }, + { "aa", "ClaudeCodeDiffAccept", desc = "Accept diff" }, + { "ad", "ClaudeCodeDiffDeny", desc = "Deny diff" }, + -- Multi-session + { "an", "ClaudeCodeNew", desc = "New session" }, + { "al", "ClaudeCodeSessions", desc = "List sessions" }, + }, +} +``` + ## Quick Demo ```vim @@ -199,6 +291,13 @@ Configure the plugin with the detected path: - `:ClaudeCodeDiffAccept` - Accept diff changes - `:ClaudeCodeDiffDeny` - Reject diff changes +**Multi-Session Commands:** + +- `:ClaudeCodeNew` - Create a new Claude terminal session +- `:ClaudeCodeSessions` - Show session picker (fzf-lua if available) +- `:ClaudeCodeSwitch ` - Switch to session by number +- `:ClaudeCodeCloseSession [number]` - Close a session (active session if no number) + ## Working with Diffs When Claude proposes changes, the plugin opens a native Neovim diff view: @@ -208,6 +307,41 @@ When Claude proposes changes, the plugin opens a native Neovim diff view: You can edit Claude's suggestions before accepting them. +## Multi-Session Support + +Run multiple Claude Code sessions simultaneously: + +- **Create sessions**: `:ClaudeCodeNew` opens a new terminal session +- **Switch sessions**: Use `:ClaudeCodeSessions` to pick from a list, or `:ClaudeCodeSwitch 2` to switch directly +- **Close sessions**: `:ClaudeCodeCloseSession` closes the active session, or `:ClaudeCodeCloseSession 2` to close a specific one + +Each session has isolated: + +- Terminal buffer and process +- WebSocket client connection +- Selection tracking context +- @ mention queue + +### Tab Bar for Sessions + +Enable a visual tab bar for managing multiple sessions: + +```lua +terminal = { + tabs = { + enabled = true, + mouse_enabled = true, -- Click tabs to switch, middle-click to close + }, +} +``` + +The tab bar shows: + +- Numbered tabs for each session (active marked with `*`) +- Close button (x) on each tab +- New session button (+) +- Supports keyboard navigation (Alt+Tab, Alt+Shift+Tab) and mouse interactions + ## How It Works This plugin creates a WebSocket server that Claude Code CLI connects to, implementing the same protocol as the official VS Code extension. When you launch Claude, it automatically detects Neovim and gains full access to your editor. @@ -238,7 +372,7 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). ```lua { - "coder/claudecode.nvim", + "snirt/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, opts = { -- Server Configuration @@ -265,6 +399,31 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). auto_close = true, snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below + -- Smart ESC handling: double-tap ESC to exit terminal mode + esc_timeout = 200, -- Timeout in ms (0 or nil to disable smart ESC) + + -- Terminal keymaps + keymaps = { + exit_terminal = "", -- Key to exit terminal mode (set to false to disable) + }, + + -- Tab bar for multi-session management + tabs = { + enabled = false, -- Enable tab bar (default: false) + height = 1, -- Height in lines + show_close_button = true, -- Show [x] close button on tabs + show_new_button = true, -- Show [+] button for new session + separator = " | ", -- Separator between tabs + active_indicator = "*", -- Indicator for active tab + mouse_enabled = false, -- Enable mouse clicks on tabs + keymaps = { + next_tab = "", -- Switch to next session + prev_tab = "", -- Switch to previous session + close_tab = "", -- Close current session + new_tab = "", -- Create new session + }, + }, + -- Provider-specific options provider_opts = { -- Command for external terminal provider. Can be: @@ -332,7 +491,7 @@ The `snacks_win_opts` configuration allows you to create floating Claude Code te local toggle_key = "" return { { - "coder/claudecode.nvim", + "snirt/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, keys = { { toggle_key, "ClaudeCodeFocus", desc = "Claude Code", mode = { "n", "x" } }, @@ -369,7 +528,7 @@ return { local toggle_key = "" -- Alt/Meta + comma return { { - "coder/claudecode.nvim", + "snirt/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, keys = { { toggle_key, "ClaudeCodeFocus", desc = "Claude Code", mode = { "n", "x" } }, @@ -421,7 +580,7 @@ require("claudecode").setup({ ```lua { - "coder/claudecode.nvim", + "snirt/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, keys = { { "", "ClaudeCodeFocus", desc = "Claude Code (Ctrl+,)", mode = { "n", "x" } }, @@ -496,7 +655,7 @@ You have to take care of launching CC and connecting it to the IDE yourself. (e. ```lua { - "coder/claudecode.nvim", + "snirt/claudecode.nvim", opts = { terminal = { provider = "none", -- no UI actions; server + tools remain available @@ -517,7 +676,7 @@ Run Claude Code in a separate terminal application outside of Neovim: ```lua -- Using a string template (simple) { - "coder/claudecode.nvim", + "snirt/claudecode.nvim", opts = { terminal = { provider = "external", @@ -531,7 +690,7 @@ Run Claude Code in a separate terminal application outside of Neovim: -- Using a function for dynamic command generation (advanced) { - "coder/claudecode.nvim", + "snirt/claudecode.nvim", opts = { terminal = { provider = "external", diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 9e9d0e5a..6684538d 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -79,6 +79,66 @@ function M.validate(config) end end + -- Validate terminal keymaps if present + if config.terminal.keymaps then + assert(type(config.terminal.keymaps) == "table", "terminal.keymaps must be a table") + if config.terminal.keymaps.exit_terminal ~= nil then + local exit_type = type(config.terminal.keymaps.exit_terminal) + assert( + exit_type == "string" or (exit_type == "boolean" and config.terminal.keymaps.exit_terminal == false), + "terminal.keymaps.exit_terminal must be a string or false" + ) + end + end + + -- Validate terminal tabs config if present + if config.terminal.tabs then + assert(type(config.terminal.tabs) == "table", "terminal.tabs must be a table") + if config.terminal.tabs.enabled ~= nil then + assert(type(config.terminal.tabs.enabled) == "boolean", "terminal.tabs.enabled must be a boolean") + end + if config.terminal.tabs.height ~= nil then + assert( + type(config.terminal.tabs.height) == "number" and config.terminal.tabs.height >= 1, + "terminal.tabs.height must be a number >= 1" + ) + end + if config.terminal.tabs.mouse_enabled ~= nil then + assert(type(config.terminal.tabs.mouse_enabled) == "boolean", "terminal.tabs.mouse_enabled must be a boolean") + end + if config.terminal.tabs.show_close_button ~= nil then + assert( + type(config.terminal.tabs.show_close_button) == "boolean", + "terminal.tabs.show_close_button must be a boolean" + ) + end + if config.terminal.tabs.show_new_button ~= nil then + assert(type(config.terminal.tabs.show_new_button) == "boolean", "terminal.tabs.show_new_button must be a boolean") + end + if config.terminal.tabs.separator ~= nil then + assert(type(config.terminal.tabs.separator) == "string", "terminal.tabs.separator must be a string") + end + if config.terminal.tabs.active_indicator ~= nil then + assert(type(config.terminal.tabs.active_indicator) == "string", "terminal.tabs.active_indicator must be a string") + end + if config.terminal.tabs.keymaps then + assert(type(config.terminal.tabs.keymaps) == "table", "terminal.tabs.keymaps must be a table") + end + end + + -- Validate cleanup_strategy if present + if config.terminal.cleanup_strategy ~= nil then + local valid_strategies = { "pkill_children", "jobstop_only", "aggressive", "none" } + local is_valid_strategy = false + for _, strategy in ipairs(valid_strategies) do + if config.terminal.cleanup_strategy == strategy then + is_valid_strategy = true + break + end + end + assert(is_valid_strategy, "terminal.cleanup_strategy must be one of: " .. table.concat(valid_strategies, ", ")) + end + local valid_log_levels = { "trace", "debug", "info", "warn", "error" } local is_valid_log_level = false for _, level in ipairs(valid_log_levels) do diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index c4b7744e..fdf91e86 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -374,6 +374,12 @@ function M.setup(opts) vim.api.nvim_create_autocmd("VimLeavePre", { group = vim.api.nvim_create_augroup("ClaudeCodeShutdown", { clear = true }), callback = function() + -- Kill all Claude terminal processes first to prevent orphans + local ok, terminal = pcall(require, "claudecode.terminal") + if ok and terminal.cleanup_all then + terminal.cleanup_all() + end + if M.state.server then M.stop() else @@ -1020,6 +1026,68 @@ function M._create_commands() end, { desc = "Close the Claude Code terminal window", }) + + -- Multi-session commands + vim.api.nvim_create_user_command("ClaudeCodeNew", function(opts) + local cmd_args = opts.args and opts.args ~= "" and opts.args or nil + local session_id = terminal.open_new_session({}, cmd_args) + logger.info("command", "Created new Claude Code session: " .. session_id) + end, { + nargs = "*", + desc = "Create a new Claude Code terminal session", + }) + + vim.api.nvim_create_user_command("ClaudeCodeSessions", function() + M.show_session_picker() + end, { + desc = "Show Claude Code session picker", + }) + + vim.api.nvim_create_user_command("ClaudeCodeSwitch", function(opts) + local session_index = opts.args and tonumber(opts.args) + if not session_index then + logger.error("command", "ClaudeCodeSwitch requires a session number") + return + end + + local sessions = terminal.list_sessions() + if session_index < 1 or session_index > #sessions then + logger.error("command", "Invalid session number: " .. session_index .. " (have " .. #sessions .. " sessions)") + return + end + + terminal.switch_to_session(sessions[session_index].id) + logger.info("command", "Switched to session " .. session_index) + end, { + nargs = 1, + desc = "Switch to Claude Code session by number", + }) + + vim.api.nvim_create_user_command("ClaudeCodeCloseSession", function(opts) + local session_index = opts.args and opts.args ~= "" and tonumber(opts.args) + + if session_index then + local sessions = terminal.list_sessions() + if session_index < 1 or session_index > #sessions then + logger.error("command", "Invalid session number: " .. session_index .. " (have " .. #sessions .. " sessions)") + return + end + terminal.close_session(sessions[session_index].id) + logger.info("command", "Closed session " .. session_index) + else + -- Close active session + local active_id = terminal.get_active_session_id() + if active_id then + terminal.close_session(active_id) + logger.info("command", "Closed active session") + else + logger.warn("command", "No active session to close") + end + end + end, { + nargs = "?", + desc = "Close a Claude Code session by number (or active session if no number)", + }) else logger.error( "init", @@ -1080,6 +1148,195 @@ M.open_with_model = function(additional_args) end) end +---Show session picker UI for selecting between active sessions +function M.show_session_picker() + local terminal = require("claudecode.terminal") + local sessions = terminal.list_sessions() + + if #sessions == 0 then + logger.warn("command", "No active Claude Code sessions") + return + end + + local active_session_id = terminal.get_active_session_id() + + -- Format session items for display + local items = {} + for i, session in ipairs(sessions) do + local age = math.floor((vim.loop.now() - session.created_at) / 1000 / 60) + local age_str + if age < 1 then + age_str = "just now" + elseif age == 1 then + age_str = "1 min ago" + else + age_str = age .. " mins ago" + end + + local active_marker = session.id == active_session_id and " (active)" or "" + table.insert(items, { + index = i, + session = session, + display = string.format("[%d] %s - %s%s", i, session.name, age_str, active_marker), + }) + end + + -- Try to use available picker (Snacks, fzf-lua, or vim.ui.select) + local pick_ok = M._try_picker(items, function(item) + if item and item.session then + terminal.switch_to_session(item.session.id) + end + end) + + if not pick_ok then + -- Fallback to vim.ui.select + vim.ui.select(items, { + prompt = "Select Claude Code session:", + format_item = function(item) + return item.display + end, + }, function(choice) + if choice and choice.session then + terminal.switch_to_session(choice.session.id) + end + end) + end +end + +---Try to use an enhanced picker (Snacks or fzf-lua) +---@param items table[] Items to pick from +---@param on_select function Callback when item is selected +---@return boolean success Whether an enhanced picker was used +function M._try_picker(items, on_select) + -- Try Snacks picker first + local snacks_ok, Snacks = pcall(require, "snacks") + if snacks_ok and Snacks and Snacks.picker then + -- Use a finder function for dynamic refresh support + local function session_finder() + local terminal_mod = require("claudecode.terminal") + local sessions = terminal_mod.list_sessions() + local active_session_id = terminal_mod.get_active_session_id() + local picker_items = {} + for i, session in ipairs(sessions) do + local age = math.floor((vim.loop.now() - session.created_at) / 1000 / 60) + local age_str + if age < 1 then + age_str = "just now" + elseif age == 1 then + age_str = "1 min ago" + else + age_str = age .. " mins ago" + end + local active_marker = session.id == active_session_id and " (active)" or "" + local display = string.format("[%d] %s - %s%s", i, session.name, age_str, active_marker) + table.insert(picker_items, { + text = display, + item = { index = i, session = session, display = display }, + }) + end + return picker_items + end + + Snacks.picker.pick({ + source = "claude_sessions", + finder = session_finder, + format = function(item) + return { { item.text } } + end, + layout = { + preview = false, + }, + confirm = function(picker, item) + picker:close() + if item and item.item then + on_select(item.item) + end + end, + actions = { + close_session = function(picker, item) + if item and item.item and item.item.session then + local terminal_mod = require("claudecode.terminal") + terminal_mod.close_session(item.item.session.id) + vim.notify("Closed session: " .. item.item.session.name, vim.log.levels.INFO) + -- Refresh the picker to show updated session list + local sessions = terminal_mod.list_sessions() + if #sessions == 0 then + picker:close() + else + picker:refresh() + end + end + end, + }, + win = { + input = { + keys = { + [""] = { "close_session", mode = { "i", "n" }, desc = "Close session" }, + }, + }, + list = { + keys = { + [""] = { "close_session", mode = { "n" }, desc = "Close session" }, + }, + }, + }, + title = "Claude Sessions (Ctrl-X: close)", + }) + return true + end + + -- Try fzf-lua + local fzf_ok, fzf = pcall(require, "fzf-lua") + if fzf_ok and fzf then + local display_items = {} + local item_map = {} + for _, item in ipairs(items) do + table.insert(display_items, item.display) + item_map[item.display] = item + end + + fzf.fzf_exec(display_items, { + prompt = "Claude Sessions> ", + actions = { + ["default"] = function(selected) + if selected and selected[1] then + local item = item_map[selected[1]] + if item then + on_select(item) + end + end + end, + ["ctrl-x"] = { + fn = function(selected) + if selected and selected[1] then + local item = item_map[selected[1]] + if item and item.session then + local terminal_mod = require("claudecode.terminal") + terminal_mod.close_session(item.session.id) + vim.notify("Closed session: " .. item.session.name, vim.log.levels.INFO) + -- Reopen picker with updated sessions if any remain + local sessions = terminal_mod.list_sessions() + if #sessions > 0 then + vim.schedule(function() + M.show_session_picker() + end) + end + end + end + end, + exec_silent = true, + }, + }, + fzf_opts = { + ["--header"] = "Enter: switch | Ctrl-X: close session", + }, + }) + return true + end + + return false +end + ---Get version information ---@return { version: string, major: integer, minor: integer, patch: integer, prerelease: string|nil } function M.get_version() diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index 9bbfed9b..7bc2d05f 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -1,8 +1,10 @@ ---Manages selection tracking and communication with the Claude server. +---Supports session-aware selection tracking for multi-session environments. ---@module 'claudecode.selection' local M = {} local logger = require("claudecode.logger") +local session_manager = require("claudecode.session") local terminal = require("claudecode.terminal") M.state = { @@ -14,6 +16,8 @@ M.state = { last_active_visual_selection = nil, demotion_timer = nil, visual_demotion_delay_ms = 50, + + mouse_handler_set = false, } ---Enables selection tracking. @@ -29,6 +33,7 @@ function M.enable(server, visual_demotion_delay_ms) M.state.visual_demotion_delay_ms = visual_demotion_delay_ms M._create_autocommands() + M._setup_mouse_handler() end ---Disables selection tracking. @@ -52,7 +57,7 @@ function M.disable() end ---Creates autocommands for tracking selections. ----Sets up listeners for CursorMoved, CursorMovedI, BufEnter, ModeChanged, and TextChanged events. +---Sets up listeners for CursorMoved, CursorMovedI, BufEnter, WinEnter, ModeChanged, and TextChanged events. ---@local function M._create_autocommands() local group = vim.api.nvim_create_augroup("ClaudeCodeSelection", { clear = true }) @@ -77,6 +82,54 @@ function M._create_autocommands() M.on_text_changed() end, }) + + vim.api.nvim_create_autocmd("WinEnter", { + group = group, + callback = function() + M.on_win_enter() + end, + }) +end + +---Sets up mouse event handler for capturing mouse-based selections. +---Uses vim.on_key to intercept mouse release events which indicate +---the end of a mouse selection drag. +---@local +function M._setup_mouse_handler() + if M.state.mouse_handler_set then + return + end + + -- Check if required APIs are available (they may not be in test environments) + if not vim.on_key or not vim.api.nvim_replace_termcodes then + return + end + + M.state.mouse_handler_set = true + + -- Cache the termcodes for mouse events + local left_release = vim.api.nvim_replace_termcodes("", true, false, true) + local left_drag = vim.api.nvim_replace_termcodes("", true, false, true) + + vim.on_key(function(key) + -- Only process if tracking is enabled + if not M.state.tracking_enabled then + return + end + + -- LeftRelease indicates end of mouse selection or click + -- LeftDrag indicates ongoing mouse selection + if key == left_release or key == left_drag then + vim.schedule(function() + -- Small delay to let Neovim update cursor/selection state + vim.defer_fn(function() + if M.state.tracking_enabled then + M.update_selection() + end + end, 10) + end) + end + end) end ---Clears the autocommands related to selection tracking. @@ -103,6 +156,25 @@ function M.on_text_changed() M.debounce_update() end +---Handles window enter events. +---Triggers an immediate update to ensure file reference is sent on keyboard navigation. +---Uses a small delay similar to mouse handler to ensure state is settled. +function M.on_win_enter() + -- Cancel any pending debounce to avoid duplicate updates + if M.state.debounce_timer then + vim.loop.timer_stop(M.state.debounce_timer) + M.state.debounce_timer = nil + end + + -- Use a small delay to ensure window/buffer state is settled + -- This mirrors the mouse handler behavior which works reliably + vim.defer_fn(function() + if M.state.tracking_enabled then + M.update_selection() + end + end, 10) +end + ---Debounces selection updates. ---Ensures that `update_selection` is not called too frequently by deferring ---its execution. @@ -236,6 +308,13 @@ function M.update_selection() if changed then M.state.latest_selection = current_selection + + -- Also update the active session's selection state + local active_session_id = session_manager.get_active_session_id() + if active_session_id then + session_manager.update_selection(active_session_id, current_selection) + end + if M.server then M.send_selection_update(current_selection) end @@ -538,8 +617,18 @@ function M.has_selection_changed(new_selection) end ---Sends the selection update to the Claude server. +---Uses session-aware sending if available, otherwise broadcasts to all. ---@param selection table The selection object to send. function M.send_selection_update(selection) + -- Try to send to active session first + if M.server.send_to_active_session then + local sent = M.server.send_to_active_session("selection_changed", selection) + if sent then + return + end + end + + -- Fallback to broadcast M.server.broadcast("selection_changed", selection) end @@ -549,6 +638,28 @@ function M.get_latest_selection() return M.state.latest_selection end +---Gets the selection for a specific session. +---@param session_id string The session ID +---@return table|nil The selection object for the session, or nil if none recorded. +function M.get_session_selection(session_id) + return session_manager.get_selection(session_id) +end + +---Gets the selection for the active session. +---Falls back to global latest_selection if no session-specific selection. +---@return table|nil The selection object, or nil if none recorded. +function M.get_active_session_selection() + local active_session_id = session_manager.get_active_session_id() + if active_session_id then + local session_selection = session_manager.get_selection(active_session_id) + if session_selection then + return session_selection + end + end + -- Fallback to global selection + return M.state.latest_selection +end + ---Sends the current selection to Claude. ---This function is typically invoked by a user command. It forces an immediate ---update and sends the latest selection. diff --git a/lua/claudecode/server/init.lua b/lua/claudecode/server/init.lua index 288c4914..c05f79a6 100644 --- a/lua/claudecode/server/init.lua +++ b/lua/claudecode/server/init.lua @@ -1,6 +1,7 @@ ---@brief WebSocket server for Claude Code Neovim integration local claudecode_main = require("claudecode") -- Added for version access local logger = require("claudecode.logger") +local session_manager = require("claudecode.session") local tcp_server = require("claudecode.server.tcp") local tools = require("claudecode.tools.init") -- Added: Require the tools module @@ -62,6 +63,16 @@ function M.start(config, auth_token) logger.debug("server", "WebSocket client connected (no auth):", client.id) end + -- Try to bind client to an available session (active session or first unbound session) + local active_session_id = session_manager.get_active_session_id() + if active_session_id then + local active_session = session_manager.get_session(active_session_id) + if active_session and not active_session.client_id then + session_manager.bind_client(active_session_id, client.id) + logger.debug("server", "Bound client", client.id, "to active session", active_session_id) + end + end + -- Notify main module about new connection for queue processing local main_module = require("claudecode") if main_module.process_mention_queue then @@ -71,6 +82,9 @@ function M.start(config, auth_token) end end, on_disconnect = function(client, code, reason) + -- Unbind client from session before removing + session_manager.unbind_client(client.id) + M.state.clients[client.id] = nil logger.debug( "server", @@ -402,6 +416,65 @@ function M.broadcast(method, params) return true end +---Send a message to a specific session's bound client +---@param session_id string The session ID +---@param method string The method name +---@param params table|nil The parameters to send +---@return boolean success Whether message was sent successfully +function M.send_to_session(session_id, method, params) + if not M.state.server then + return false + end + + local session = session_manager.get_session(session_id) + if not session or not session.client_id then + logger.debug("server", "Cannot send to session", session_id, "- no bound client") + return false + end + + local client = M.state.clients[session.client_id] + if not client then + logger.debug("server", "Cannot send to session", session_id, "- client not found") + return false + end + + return M.send(client, method, params) +end + +---Send a message to the active session's bound client +---@param method string The method name +---@param params table|nil The parameters to send +---@return boolean success Whether message was sent successfully +function M.send_to_active_session(method, params) + local active_session_id = session_manager.get_active_session_id() + if not active_session_id then + -- Fallback to broadcast if no active session + logger.debug("server", "No active session, falling back to broadcast") + return M.broadcast(method, params) + end + + return M.send_to_session(active_session_id, method, params) +end + +---Get the session ID for a client +---@param client_id string The client ID +---@return string|nil session_id The session ID or nil +function M.get_client_session(client_id) + local session = session_manager.find_session_by_client(client_id) + if session then + return session.id + end + return nil +end + +---Bind a client to a session +---@param client_id string The client ID +---@param session_id string The session ID +---@return boolean success Whether binding was successful +function M.bind_client_to_session(client_id, session_id) + return session_manager.bind_client(session_id, client_id) +end + ---Get server status information ---@return table status Server status information function M.get_status() diff --git a/lua/claudecode/session.lua b/lua/claudecode/session.lua new file mode 100644 index 00000000..ffbfbe1e --- /dev/null +++ b/lua/claudecode/session.lua @@ -0,0 +1,363 @@ +---Session manager for multiple Claude Code terminal sessions. +---Provides full session isolation with independent state tracking per session. +---@module 'claudecode.session' + +local M = {} + +local logger = require("claudecode.logger") + +---@class ClaudeCodeSession +---@field id string Unique session identifier +---@field terminal_bufnr number|nil Buffer number for the terminal +---@field terminal_winid number|nil Window ID for the terminal +---@field terminal_jobid number|nil Job ID for the terminal process +---@field client_id string|nil Bound WebSocket client ID +---@field selection table|nil Session-specific selection state +---@field mention_queue table Queue for @ mentions +---@field created_at number Timestamp when session was created +---@field name string|nil Optional display name for the session + +---@type table +M.sessions = {} + +---@type string|nil Currently active session ID +M.active_session_id = nil + +---@type number Session counter for generating sequential IDs +local session_counter = 0 + +---Generate a unique session ID +---@return string session_id +local function generate_session_id() + session_counter = session_counter + 1 + return string.format("session_%d_%d", session_counter, vim.loop.now()) +end + +---Create a new session +---@param opts table|nil Optional configuration { name?: string } +---@return string session_id The ID of the created session +function M.create_session(opts) + opts = opts or {} + local session_id = generate_session_id() + + ---@type ClaudeCodeSession + local session = { + id = session_id, + terminal_bufnr = nil, + terminal_winid = nil, + terminal_jobid = nil, + client_id = nil, + selection = nil, + mention_queue = {}, + created_at = vim.loop.now(), + name = opts.name or string.format("Session %d", session_counter), + } + + M.sessions[session_id] = session + + -- If this is the first session, make it active + if not M.active_session_id then + M.active_session_id = session_id + end + + logger.debug("session", "Created session: " .. session_id .. " (" .. session.name .. ")") + + -- Emit autocmd event for UI integrations (tab bar, statusline, etc.) + pcall(vim.api.nvim_exec_autocmds, "User", { + pattern = "ClaudeCodeSessionCreated", + data = { session_id = session_id, name = session.name }, + }) + + return session_id +end + +---Destroy a session and clean up resources +---@param session_id string The session ID to destroy +---@return boolean success Whether the session was destroyed +function M.destroy_session(session_id) + local session = M.sessions[session_id] + if not session then + logger.warn("session", "Cannot destroy non-existent session: " .. session_id) + return false + end + + -- Clear mention queue + session.mention_queue = {} + + -- Clean up selection state + session.selection = nil + + -- Remove from sessions table + M.sessions[session_id] = nil + + -- If this was the active session, switch to another or clear + if M.active_session_id == session_id then + -- Get first available session using next() + local next_session_id = next(M.sessions) + M.active_session_id = next_session_id + end + + logger.debug("session", "Destroyed session: " .. session_id) + + -- Emit autocmd event for UI integrations (tab bar, statusline, etc.) + pcall(vim.api.nvim_exec_autocmds, "User", { + pattern = "ClaudeCodeSessionDestroyed", + data = { session_id = session_id }, + }) + + return true +end + +---Get a session by ID +---@param session_id string The session ID +---@return ClaudeCodeSession|nil session The session or nil if not found +function M.get_session(session_id) + return M.sessions[session_id] +end + +---Get the active session +---@return ClaudeCodeSession|nil session The active session or nil +function M.get_active_session() + if not M.active_session_id then + return nil + end + return M.sessions[M.active_session_id] +end + +---Get the active session ID +---@return string|nil session_id The active session ID or nil +function M.get_active_session_id() + return M.active_session_id +end + +---Set the active session +---@param session_id string The session ID to make active +---@return boolean success Whether the session was activated +function M.set_active_session(session_id) + if not M.sessions[session_id] then + logger.warn("session", "Cannot activate non-existent session: " .. session_id) + return false + end + + M.active_session_id = session_id + logger.debug("session", "Activated session: " .. session_id) + + return true +end + +---List all sessions +---@return ClaudeCodeSession[] sessions Array of all sessions +function M.list_sessions() + local sessions = {} + for _, session in pairs(M.sessions) do + table.insert(sessions, session) + end + + -- Sort by creation time + table.sort(sessions, function(a, b) + return a.created_at < b.created_at + end) + + return sessions +end + +---Get session count +---@return number count Number of active sessions +function M.get_session_count() + local count = 0 + for _ in pairs(M.sessions) do + count = count + 1 + end + return count +end + +---Find session by terminal buffer number +---@param bufnr number The buffer number to search for +---@return ClaudeCodeSession|nil session The session or nil +function M.find_session_by_bufnr(bufnr) + for _, session in pairs(M.sessions) do + if session.terminal_bufnr == bufnr then + return session + end + end + return nil +end + +---Find session by WebSocket client ID +---@param client_id string The client ID to search for +---@return ClaudeCodeSession|nil session The session or nil +function M.find_session_by_client(client_id) + for _, session in pairs(M.sessions) do + if session.client_id == client_id then + return session + end + end + return nil +end + +---Bind a WebSocket client to a session +---@param session_id string The session ID +---@param client_id string The client ID to bind +---@return boolean success Whether the binding was successful +function M.bind_client(session_id, client_id) + local session = M.sessions[session_id] + if not session then + logger.warn("session", "Cannot bind client to non-existent session: " .. session_id) + return false + end + + -- Check if client is already bound to another session + local existing_session = M.find_session_by_client(client_id) + if existing_session and existing_session.id ~= session_id then + logger.warn("session", "Client " .. client_id .. " already bound to session " .. existing_session.id) + return false + end + + session.client_id = client_id + logger.debug("session", "Bound client " .. client_id .. " to session " .. session_id) + + return true +end + +---Unbind a WebSocket client from its session +---@param client_id string The client ID to unbind +---@return boolean success Whether the unbinding was successful +function M.unbind_client(client_id) + local session = M.find_session_by_client(client_id) + if not session then + return false + end + + session.client_id = nil + logger.debug("session", "Unbound client " .. client_id .. " from session " .. session.id) + + return true +end + +---Update session terminal info +---@param session_id string The session ID +---@param terminal_info table { bufnr?: number, winid?: number, jobid?: number } +function M.update_terminal_info(session_id, terminal_info) + local session = M.sessions[session_id] + if not session then + return + end + + if terminal_info.bufnr ~= nil then + session.terminal_bufnr = terminal_info.bufnr + end + if terminal_info.winid ~= nil then + session.terminal_winid = terminal_info.winid + end + if terminal_info.jobid ~= nil then + session.terminal_jobid = terminal_info.jobid + end +end + +---Update session selection +---@param session_id string The session ID +---@param selection table|nil The selection data +function M.update_selection(session_id, selection) + local session = M.sessions[session_id] + if not session then + return + end + + session.selection = selection +end + +---Update session name (typically from terminal title) +---@param session_id string The session ID +---@param name string The new name +function M.update_session_name(session_id, name) + local session = M.sessions[session_id] + if not session then + logger.warn("session", "Cannot update name for non-existent session: " .. session_id) + return + end + + -- Strip "Claude - " prefix (redundant for Claude sessions) + name = name:gsub("^[Cc]laude %- ", "") + + -- Sanitize: trim whitespace and limit length + name = name:gsub("^%s+", ""):gsub("%s+$", "") + if #name > 100 then + name = name:sub(1, 97) .. "..." + end + + -- Don't update if name is empty or unchanged + if name == "" or session.name == name then + return + end + + local old_name = session.name + session.name = name + + logger.debug("session", string.format("Updated session name: '%s' -> '%s' (%s)", old_name, name, session_id)) + + -- Emit autocmd event for UI integrations (statusline, session pickers, etc.) + -- Use pcall to handle case where nvim_exec_autocmds may not exist (e.g., in tests) + pcall(vim.api.nvim_exec_autocmds, "User", { + pattern = "ClaudeCodeSessionNameChanged", + data = { session_id = session_id, name = name, old_name = old_name }, + }) +end + +---Get session selection +---@param session_id string The session ID +---@return table|nil selection The selection data or nil +function M.get_selection(session_id) + local session = M.sessions[session_id] + if not session then + return nil + end + + return session.selection +end + +---Add mention to session queue +---@param session_id string The session ID +---@param mention table The mention data +function M.queue_mention(session_id, mention) + local session = M.sessions[session_id] + if not session then + return + end + + table.insert(session.mention_queue, mention) +end + +---Get and clear session mention queue +---@param session_id string The session ID +---@return table mentions Array of mentions +function M.flush_mention_queue(session_id) + local session = M.sessions[session_id] + if not session then + return {} + end + + local mentions = session.mention_queue + session.mention_queue = {} + return mentions +end + +---Get or create a session (ensures at least one session exists) +---@return string session_id The session ID +function M.ensure_session() + if M.active_session_id and M.sessions[M.active_session_id] then + return M.active_session_id + end + + -- No active session, create one + return M.create_session() +end + +---Reset all session state (for testing or cleanup) +function M.reset() + M.sessions = {} + M.active_session_id = nil + session_counter = 0 + logger.debug("session", "Reset all sessions") +end + +return M diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index fae0b30f..1151e7c7 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -1,10 +1,98 @@ ---- Module to manage a dedicated vertical split terminal for Claude Code. +--- Module to manage dedicated vertical split terminals for Claude Code. --- Supports Snacks.nvim or a native Neovim terminal fallback. +--- Now supports multiple concurrent terminal sessions. --- @module 'claudecode.terminal' local M = {} local claudecode_server_module = require("claudecode.server.init") +local osc_handler = require("claudecode.terminal.osc_handler") +local session_manager = require("claudecode.session") + +-- Use global to survive module reloads (Fix 3: Plugin Reload Protection) +---@type table Map of job_id -> unix_pid +_G._claudecode_tracked_pids = _G._claudecode_tracked_pids or {} +local tracked_pids = _G._claudecode_tracked_pids + +-- Buffer to session mapping for cleanup on BufUnload (Fix 1: Zombie Sessions) +---@type table Map of bufnr -> session_id +_G._claudecode_buffer_to_session = _G._claudecode_buffer_to_session or {} +local buffer_to_session = _G._claudecode_buffer_to_session + +---Cleanup orphaned PIDs from previous module load (Fix 3: Plugin Reload Protection) +---Called on module load to kill any processes that were orphaned by a plugin reload +local function cleanup_orphaned_pids() + for job_id, pid in pairs(tracked_pids) do + -- Check if job still exists + local exists = pcall(vim.fn.jobpid, job_id) + if not exists then + -- Job doesn't exist but PID tracked - orphaned + if pid and pid > 0 then + pcall(vim.fn.system, "pkill -TERM -P " .. pid .. " 2>/dev/null") + pcall(vim.fn.system, "kill -TERM " .. pid .. " 2>/dev/null") + end + tracked_pids[job_id] = nil + end + end +end + +-- Run cleanup on module load +cleanup_orphaned_pids() + +---Track a terminal job's PID for cleanup on exit +---@param job_id number The Neovim job ID +function M.track_terminal_pid(job_id) + if not job_id then + return + end + local ok, pid = pcall(vim.fn.jobpid, job_id) + if ok and pid and pid > 0 then + tracked_pids[job_id] = pid + end +end + +---Untrack a terminal job (called when terminal exits normally) +---@param job_id number The Neovim job ID +function M.untrack_terminal_pid(job_id) + if job_id then + tracked_pids[job_id] = nil + end +end + +---Register a buffer-to-session mapping for cleanup on BufUnload (Fix 1) +---@param bufnr number The buffer number +---@param session_id string The session ID +function M.register_buffer_session(bufnr, session_id) + if bufnr and session_id then + buffer_to_session[bufnr] = session_id + end +end + +---Unregister a buffer-to-session mapping (called when session is properly destroyed) +---@param bufnr number The buffer number +function M.unregister_buffer_session(bufnr) + if bufnr then + buffer_to_session[bufnr] = nil + end +end + +-- Setup global BufUnload handler to cleanup orphaned sessions (Fix 1: Zombie Sessions) +-- This catches :bd! and other direct buffer deletions that bypass close_session() +vim.api.nvim_create_autocmd("BufUnload", { + group = vim.api.nvim_create_augroup("ClaudeCodeBufferCleanup", { clear = true }), + callback = function(ev) + local session_id = buffer_to_session[ev.buf] + if session_id then + buffer_to_session[ev.buf] = nil + -- Destroy orphaned session if it still exists + if session_manager.get_session(session_id) then + local logger = require("claudecode.logger") + logger.debug("terminal", "Auto-destroying orphaned session on BufUnload: " .. session_id) + session_manager.destroy_session(session_id) + end + end + end, +}) ---@type ClaudeCodeTerminalConfig local defaults = { @@ -23,10 +111,147 @@ local defaults = { cwd = nil, -- static cwd override git_repo_cwd = false, -- resolve to git root when spawning cwd_provider = nil, -- function(ctx) -> cwd string + -- Terminal keymaps + keymaps = { + exit_terminal = "", -- Double-ESC to exit terminal mode (set to false to disable) + }, + -- Smart ESC handling: timeout in ms to wait for second ESC before sending ESC to terminal + -- Set to nil or 0 to disable smart ESC handling (use simple keymap instead) + esc_timeout = 200, + -- Process cleanup strategy when Neovim exits + -- "pkill_children" - Kill child processes first, then shell (recommended, fixes race condition) + -- "jobstop_only" - Only use Neovim's jobstop (relies on shell forwarding SIGTERM) + -- "aggressive" - Use SIGKILL for guaranteed termination (may leave state) + -- "none" - Don't kill processes on exit (manual cleanup) + cleanup_strategy = "pkill_children", + -- Tab bar for session switching (optional) + tabs = { + enabled = false, -- Off by default + height = 1, -- Height of tab bar in lines + show_close_button = true, -- Show [x] close button on tabs + show_new_button = true, -- Show [+] button for new session + separator = " | ", -- Separator between tabs + active_indicator = "*", -- Indicator for active tab + mouse_enabled = false, -- Mouse clicks optional, off by default + keymaps = { + next_tab = "", -- Switch to next session (Alt+Tab) + prev_tab = "", -- Switch to previous session (Alt+Shift+Tab) + close_tab = "", -- Close current tab (Alt+w) + new_tab = "", -- Create new session (Alt++) + }, + }, } M.defaults = defaults +-- ============================================================================ +-- Smart ESC handler for terminal mode +-- ============================================================================ + +-- State for tracking ESC key presses per buffer +local esc_state = {} + +---Creates a smart ESC handler for a terminal buffer. +---This handler intercepts ESC presses and waits for a second ESC within the timeout. +---If a second ESC arrives, it exits terminal mode. Otherwise, sends ESC to the terminal. +---@param bufnr number The terminal buffer number +---@param timeout_ms number Timeout in milliseconds to wait for second ESC +---@return function handler The ESC key handler function +function M.create_smart_esc_handler(bufnr, timeout_ms) + return function() + local state = esc_state[bufnr] + + if state and state.waiting then + -- Second ESC within timeout - exit terminal mode + state.waiting = false + if state.timer then + state.timer:stop() + state.timer:close() + state.timer = nil + end + -- Exit terminal mode + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", false) + else + -- First ESC - start waiting for second ESC + esc_state[bufnr] = { waiting = true, timer = nil } + state = esc_state[bufnr] + + state.timer = vim.uv.new_timer() + state.timer:start( + timeout_ms, + 0, + vim.schedule_wrap(function() + -- Timeout expired - send ESC to the terminal + if esc_state[bufnr] and esc_state[bufnr].waiting then + esc_state[bufnr].waiting = false + if esc_state[bufnr].timer then + esc_state[bufnr].timer:stop() + esc_state[bufnr].timer:close() + esc_state[bufnr].timer = nil + end + -- Send ESC directly to the terminal channel, bypassing keymaps + -- Get the terminal channel from the buffer + if vim.api.nvim_buf_is_valid(bufnr) then + local channel = vim.bo[bufnr].channel + if channel and channel > 0 then + -- Send raw ESC byte (0x1b = 27) directly to terminal + vim.fn.chansend(channel, "\027") + end + end + end + end) + ) + end + end +end + +---Sets up smart ESC handling for a terminal buffer. +---If smart ESC is enabled (esc_timeout > 0), maps single ESC to smart handler. +---Otherwise falls back to simple double-ESC mapping. +---@param bufnr number The terminal buffer number +---@param config table The terminal configuration (with keymaps and esc_timeout) +function M.setup_terminal_keymaps(bufnr, config) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local timeout = config.esc_timeout + local exit_key = config.keymaps and config.keymaps.exit_terminal + + if exit_key == false then + -- ESC handling disabled + return + end + + if timeout and timeout > 0 then + -- Smart ESC handling: intercept single ESC + local handler = M.create_smart_esc_handler(bufnr, timeout) + vim.keymap.set("t", "", handler, { + buffer = bufnr, + desc = "Smart ESC: double-tap to exit terminal mode, single to send ESC", + }) + elseif exit_key then + -- Fallback: simple keymap (legacy behavior) + vim.keymap.set("t", exit_key, "", { + buffer = bufnr, + desc = "Exit terminal mode", + }) + end +end + +---Cleanup ESC state for a buffer (call when buffer is deleted) +---@param bufnr number The terminal buffer number +function M.cleanup_esc_state(bufnr) + local state = esc_state[bufnr] + if state then + if state.timer then + state.timer:stop() + state.timer:close() + end + esc_state[bufnr] = nil + end +end + -- Lazy load providers local providers = {} @@ -270,6 +495,8 @@ local function build_config(opts_override) auto_close = effective_config.auto_close, snacks_win_opts = effective_config.snacks_win_opts, cwd = resolved_cwd, + keymaps = effective_config.keymaps, + esc_timeout = effective_config.esc_timeout, } end @@ -281,10 +508,48 @@ local function is_terminal_visible(bufnr) return false end - local bufinfo = vim.fn.getbufinfo(bufnr) + -- Protect against missing vim.fn.getbufinfo in test environment + if not vim.fn or not vim.fn.getbufinfo then + return false + end + + local ok, bufinfo = pcall(vim.fn.getbufinfo, bufnr) + if not ok then + return false + end return bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 end +---Attach the tab bar to a terminal window if tabs are enabled +---@param terminal_winid number The terminal window ID +---@param terminal_bufnr number|nil The terminal buffer number (for keymaps) +local function attach_tabbar(terminal_winid, terminal_bufnr) + if not defaults.tabs or not defaults.tabs.enabled then + return + end + + if not terminal_winid or not vim.api.nvim_win_is_valid(terminal_winid) then + return + end + + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(terminal_winid, terminal_bufnr) + end +end + +---Detach the tab bar from the terminal +local function detach_tabbar() + if not defaults.tabs or not defaults.tabs.enabled then + return + end + + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.detach() + end +end + ---Gets the claude command string and necessary environment variables ---@param cmd_args string? Optional arguments to append to the command ---@return string cmd_string The command string @@ -338,6 +603,7 @@ local function ensure_terminal_visible_no_focus(opts_override, cmd_args) end local active_bufnr = provider.get_active_bufnr() + local had_terminal = active_bufnr ~= nil if is_terminal_visible(active_bufnr) then -- Terminal is already visible, do nothing @@ -349,6 +615,24 @@ local function ensure_terminal_visible_no_focus(opts_override, cmd_args) 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 + + -- If we didn't have a terminal before but do now, ensure a session exists + if not had_terminal then + local new_bufnr = provider.get_active_bufnr() + if new_bufnr then + -- Ensure we have a session for this terminal + local session_id = session_manager.ensure_session() + -- Update session with terminal info + session_manager.update_terminal_info(session_id, { + bufnr = new_bufnr, + }) + -- Register terminal with provider for session switching support + if provider.register_terminal_for_session then + provider.register_terminal_for_session(session_id, new_bufnr) + end + end + end + return true end @@ -482,6 +766,113 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) else vim.notify("claudecode.terminal.setup: Invalid cwd_provider type: " .. tostring(t), vim.log.levels.WARN) end + elseif k == "keymaps" then + if type(v) == "table" then + defaults.keymaps = defaults.keymaps or {} + for keymap_k, keymap_v in pairs(v) do + if keymap_k == "exit_terminal" then + if keymap_v == false or type(keymap_v) == "string" then + defaults.keymaps.exit_terminal = keymap_v + else + vim.notify( + "claudecode.terminal.setup: Invalid value for keymaps.exit_terminal: " + .. tostring(keymap_v) + .. ". Must be a string or false.", + vim.log.levels.WARN + ) + end + else + vim.notify("claudecode.terminal.setup: Unknown keymap key: " .. tostring(keymap_k), vim.log.levels.WARN) + end + end + else + vim.notify( + "claudecode.terminal.setup: Invalid value for keymaps: " .. tostring(v) .. ". Must be a table.", + vim.log.levels.WARN + ) + end + elseif k == "esc_timeout" then + if v == nil or (type(v) == "number" and v >= 0) then + defaults.esc_timeout = v + else + vim.notify( + "claudecode.terminal.setup: Invalid value for esc_timeout: " + .. tostring(v) + .. ". Must be a number >= 0 or nil.", + vim.log.levels.WARN + ) + end + elseif k == "cleanup_strategy" then + local valid_strategies = { pkill_children = true, jobstop_only = true, aggressive = true, none = true } + if valid_strategies[v] then + defaults.cleanup_strategy = v + else + vim.notify( + "claudecode.terminal.setup: Invalid value for cleanup_strategy: " + .. tostring(v) + .. ". Must be one of: pkill_children, jobstop_only, aggressive, none.", + vim.log.levels.WARN + ) + end + elseif k == "tabs" then + if type(v) == "table" then + defaults.tabs = defaults.tabs or {} + for tabs_k, tabs_v in pairs(v) do + if tabs_k == "enabled" then + if type(tabs_v) == "boolean" then + defaults.tabs.enabled = tabs_v + else + vim.notify( + "claudecode.terminal.setup: Invalid value for tabs.enabled: " .. tostring(tabs_v), + vim.log.levels.WARN + ) + end + elseif tabs_k == "height" then + if type(tabs_v) == "number" and tabs_v >= 1 then + defaults.tabs.height = tabs_v + else + vim.notify( + "claudecode.terminal.setup: Invalid value for tabs.height: " .. tostring(tabs_v), + vim.log.levels.WARN + ) + end + elseif tabs_k == "show_close_button" then + if type(tabs_v) == "boolean" then + defaults.tabs.show_close_button = tabs_v + end + elseif tabs_k == "show_new_button" then + if type(tabs_v) == "boolean" then + defaults.tabs.show_new_button = tabs_v + end + elseif tabs_k == "separator" then + if type(tabs_v) == "string" then + defaults.tabs.separator = tabs_v + end + elseif tabs_k == "active_indicator" then + if type(tabs_v) == "string" then + defaults.tabs.active_indicator = tabs_v + end + elseif tabs_k == "mouse_enabled" then + if type(tabs_v) == "boolean" then + defaults.tabs.mouse_enabled = tabs_v + end + elseif tabs_k == "keymaps" then + if type(tabs_v) == "table" then + defaults.tabs.keymaps = defaults.tabs.keymaps or {} + for km_k, km_v in pairs(tabs_v) do + if km_v == false or type(km_v) == "string" then + defaults.tabs.keymaps[km_k] = km_v + end + end + end + end + end + else + vim.notify( + "claudecode.terminal.setup: Invalid value for tabs: " .. tostring(v) .. ". Must be a table.", + vim.log.levels.WARN + ) + end else if k ~= "terminal_cmd" then vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN) @@ -489,8 +880,23 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) end end + -- Setup window manager with config + local window_manager = require("claudecode.terminal.window_manager") + window_manager.setup({ + split_side = defaults.split_side, + split_width_percentage = defaults.split_width_percentage, + }) + -- Setup providers with config get_provider().setup(defaults) + + -- Setup tab bar if configured + if defaults.tabs then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.setup(defaults.tabs) + end + end end ---Opens or focuses the Claude terminal. @@ -500,11 +906,42 @@ function M.open(opts_override, cmd_args) local effective_config = build_config(opts_override) local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) - get_provider().open(cmd_string, claude_env_table, effective_config) + local provider = get_provider() + local had_terminal = provider.get_active_bufnr() ~= nil + + provider.open(cmd_string, claude_env_table, effective_config) + + -- If we didn't have a terminal before but do now, ensure a session exists + if not had_terminal then + local active_bufnr = provider.get_active_bufnr() + if active_bufnr then + -- Ensure we have a session for this terminal + local session_id = session_manager.ensure_session() + -- Update session with terminal info + session_manager.update_terminal_info(session_id, { + bufnr = active_bufnr, + }) + -- Register terminal with provider for session switching support + if provider.register_terminal_for_session then + provider.register_terminal_for_session(session_id, active_bufnr) + end + end + end + + -- Attach tab bar if enabled (find terminal window from buffer) + local active_bufnr = provider.get_active_bufnr() + if active_bufnr and vim.fn.getbufinfo then + local ok, bufinfo = pcall(vim.fn.getbufinfo, active_bufnr) + if ok and bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 then + attach_tabbar(bufinfo[1].windows[1], active_bufnr) + end + end end ---Closes the managed Claude terminal if it's open and valid. function M.close() + detach_tabbar() + -- Call provider's close for backwards compatibility get_provider().close() end @@ -515,7 +952,52 @@ function M.simple_toggle(opts_override, cmd_args) local effective_config = build_config(opts_override) local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) - get_provider().simple_toggle(cmd_string, claude_env_table, effective_config) + -- Check if we had a terminal before the toggle + local provider = get_provider() + local had_terminal = provider.get_active_bufnr() ~= nil + local was_visible = is_terminal_visible(provider.get_active_bufnr()) + + provider.simple_toggle(cmd_string, claude_env_table, effective_config) + + -- If we didn't have a terminal before but do now, ensure a session exists + if not had_terminal then + local active_bufnr = provider.get_active_bufnr() + if active_bufnr then + -- Ensure we have a session for this terminal + local session_id = session_manager.ensure_session() + -- Update session with terminal info + session_manager.update_terminal_info(session_id, { + bufnr = active_bufnr, + }) + -- Register terminal with provider for session switching support + if provider.register_terminal_for_session then + provider.register_terminal_for_session(session_id, active_bufnr) + end + -- Setup title watcher to capture terminal title changes + osc_handler.setup_buffer_handler(active_bufnr, function(title) + if title and title ~= "" then + session_manager.update_session_name(session_id, title) + end + end) + end + end + + -- Handle tab bar visibility based on terminal visibility + local active_bufnr = provider.get_active_bufnr() + local is_visible_now = is_terminal_visible(active_bufnr) + + if is_visible_now and not was_visible then + -- Terminal just became visible, attach tab bar + if active_bufnr and vim.fn.getbufinfo then + local ok, bufinfo = pcall(vim.fn.getbufinfo, active_bufnr) + if ok and bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 then + attach_tabbar(bufinfo[1].windows[1], active_bufnr) + end + end + elseif was_visible and not is_visible_now then + -- Terminal was hidden, detach tab bar + detach_tabbar() + end end ---Smart focus toggle: switches to terminal if not focused, hides if currently focused. @@ -525,7 +1007,52 @@ function M.focus_toggle(opts_override, cmd_args) local effective_config = build_config(opts_override) local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) - get_provider().focus_toggle(cmd_string, claude_env_table, effective_config) + -- Check if we had a terminal before the toggle + local provider = get_provider() + local had_terminal = provider.get_active_bufnr() ~= nil + local was_visible = is_terminal_visible(provider.get_active_bufnr()) + + provider.focus_toggle(cmd_string, claude_env_table, effective_config) + + -- If we didn't have a terminal before but do now, ensure a session exists + if not had_terminal then + local active_bufnr = provider.get_active_bufnr() + if active_bufnr then + -- Ensure we have a session for this terminal + local session_id = session_manager.ensure_session() + -- Update session with terminal info + session_manager.update_terminal_info(session_id, { + bufnr = active_bufnr, + }) + -- Register terminal with provider for session switching support + if provider.register_terminal_for_session then + provider.register_terminal_for_session(session_id, active_bufnr) + end + -- Setup OSC title handler to capture terminal title changes + osc_handler.setup_buffer_handler(active_bufnr, function(title) + if title and title ~= "" then + session_manager.update_session_name(session_id, title) + end + end) + end + end + + -- Handle tab bar visibility based on terminal visibility + local active_bufnr = provider.get_active_bufnr() + local is_visible_now = is_terminal_visible(active_bufnr) + + if is_visible_now and not was_visible then + -- Terminal just became visible, attach tab bar + if active_bufnr and vim.fn.getbufinfo then + local ok, bufinfo = pcall(vim.fn.getbufinfo, active_bufnr) + if ok and bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 then + attach_tabbar(bufinfo[1].windows[1], active_bufnr) + end + end + elseif was_visible and not is_visible_now then + -- Terminal was hidden, detach tab bar + detach_tabbar() + end end ---Toggle open terminal without focus if not already visible, otherwise do nothing. @@ -569,4 +1096,362 @@ function M._get_managed_terminal_for_test() return nil end +-- ============================================================================ +-- Multi-session support functions +-- ============================================================================ + +---Opens a new Claude terminal session. +---@param opts_override table? Overrides for terminal appearance (split_side, split_width_percentage). +---@param cmd_args string? Arguments to append to the claude command. +---@return string session_id The ID of the new session +function M.open_new_session(opts_override, cmd_args) + local session_id = session_manager.create_session() + local effective_config = build_config(opts_override) + local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) + + -- Make the new session active immediately + session_manager.set_active_session(session_id) + + local provider = get_provider() + + -- For multi-session, we need to pass session_id to providers + if provider.open_session then + provider.open_session(session_id, cmd_string, claude_env_table, effective_config, true) -- true = focus + else + -- Fallback: use regular open (single terminal mode) + provider.open(cmd_string, claude_env_table, effective_config, true) -- true = focus + end + + return session_id +end + +---Closes a specific session. +---@param session_id string? The session ID to close (defaults to active session) +function M.close_session(session_id) + session_id = session_id or session_manager.get_active_session_id() + if not session_id then + return + end + + local provider = get_provider() + local effective_config = build_config(nil) + + -- Check if there are other sessions to switch to + local session_count = session_manager.get_session_count() + + if session_count > 1 then + -- There are other sessions - keep the window and switch to another session + -- Figure out which session to switch to: prefer previous tab, fallback to next + local sessions = session_manager.list_sessions() + local new_active_id = nil + local current_index = nil + + -- Find the index of the session being closed + for i, s in ipairs(sessions) do + if s.id == session_id then + current_index = i + break + end + end + + if current_index then + -- Prefer previous tab (index - 1), fallback to next tab (index + 1) + if current_index > 1 then + new_active_id = sessions[current_index - 1].id + elseif current_index < #sessions then + new_active_id = sessions[current_index + 1].id + end + end + + -- Fallback: just pick any other session + if not new_active_id then + for _, s in ipairs(sessions) do + if s.id ~= session_id then + new_active_id = s.id + break + end + end + end + + if new_active_id and provider.close_session_keep_window then + -- Use close_session_keep_window to keep window open and switch buffer + -- This function handles cleanup of the old session internally + provider.close_session_keep_window(session_id, new_active_id, effective_config) + session_manager.destroy_session(session_id) + session_manager.set_active_session(new_active_id) + else + -- Fallback: close and reopen + session_manager.destroy_session(session_id) + new_active_id = session_manager.get_active_session_id() + + if provider.close_session then + provider.close_session(session_id) + else + provider.close() + end + + if new_active_id and provider.focus_session then + provider.focus_session(new_active_id, effective_config) + end + end + + -- Re-attach tabbar to the new session's terminal + if new_active_id then + local new_bufnr + if provider.get_session_bufnr then + new_bufnr = provider.get_session_bufnr(new_active_id) + else + new_bufnr = provider.get_active_bufnr() + end + + if new_bufnr and vim.fn.getbufinfo then + local ok, bufinfo = pcall(vim.fn.getbufinfo, new_bufnr) + if ok and bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 then + attach_tabbar(bufinfo[1].windows[1], new_bufnr) + end + end + end + else + -- This is the last session - close everything + detach_tabbar() + + if provider.close_session then + provider.close_session(session_id) + else + provider.close() + end + + session_manager.destroy_session(session_id) + end +end + +---Switches to a specific session. +---@param session_id string The session ID to switch to +---@param opts_override table? Optional config overrides +function M.switch_to_session(session_id, opts_override) + local session = session_manager.get_session(session_id) + if not session then + local logger = require("claudecode.logger") + logger.warn("terminal", "Cannot switch to non-existent session: " .. session_id) + return + end + + session_manager.set_active_session(session_id) + + local provider = get_provider() + + if provider.focus_session then + local effective_config = build_config(opts_override) + provider.focus_session(session_id, effective_config) + elseif session.terminal_bufnr and vim.api.nvim_buf_is_valid(session.terminal_bufnr) then + -- Fallback: try to find and focus the window + local windows = vim.api.nvim_list_wins() + for _, win in ipairs(windows) do + if vim.api.nvim_win_get_buf(win) == session.terminal_bufnr then + vim.api.nvim_set_current_win(win) + vim.cmd("startinsert") + return + end + end + end +end + +---Gets the session ID for the currently focused terminal. +---@return string|nil session_id The session ID or nil if not in a session terminal +function M.get_current_session_id() + local current_buf = vim.api.nvim_get_current_buf() + local session = session_manager.find_session_by_bufnr(current_buf) + if session then + return session.id + end + return nil +end + +---Lists all active sessions. +---@return table[] sessions Array of session info +function M.list_sessions() + return session_manager.list_sessions() +end + +---Gets the number of active sessions. +---@return number count Number of active sessions +function M.get_session_count() + return session_manager.get_session_count() +end + +---Updates terminal info for a session (called by providers). +---@param session_id string The session ID +---@param terminal_info table { bufnr?: number, winid?: number, jobid?: number } +function M.update_session_terminal_info(session_id, terminal_info) + session_manager.update_terminal_info(session_id, terminal_info) +end + +---Gets the active session ID. +---@return string|nil session_id The active session ID +function M.get_active_session_id() + return session_manager.get_active_session_id() +end + +---Ensures at least one session exists and returns its ID. +---@return string session_id The session ID +function M.ensure_session() + return session_manager.ensure_session() +end + +---Cleanup all terminal processes (called on Neovim exit). +---Ensures no orphan Claude processes remain by killing all terminal jobs. +---Uses the configured cleanup_strategy to determine how processes are terminated. +---Implements defense-in-depth: recovers PIDs from sessions and terminal buffers +---even if they weren't properly tracked. +function M.cleanup_all() + local logger = require("claudecode.logger") + local strategy = defaults.cleanup_strategy or "pkill_children" + + -- Defense-in-depth: Recover PIDs from session manager + -- This catches any terminals whose PIDs weren't properly tracked + local session_mgr_ok, session_mgr = pcall(require, "claudecode.session") + if session_mgr_ok and session_mgr.list_sessions then + for _, session in ipairs(session_mgr.list_sessions()) do + if session.terminal_jobid and not tracked_pids[session.terminal_jobid] then + local pid_ok, pid = pcall(vim.fn.jobpid, session.terminal_jobid) + if pid_ok and pid and pid > 0 then + tracked_pids[session.terminal_jobid] = pid + logger.debug("terminal", "Recovered PID " .. pid .. " from session " .. session.id) + end + end + end + end + + -- Defense-in-depth: Recover PIDs from terminal buffers + -- This catches any terminal buffers that weren't associated with sessions + local list_bufs_ok, bufs = pcall(vim.api.nvim_list_bufs) + if list_bufs_ok and bufs then + for _, bufnr in ipairs(bufs) do + local valid_ok, is_valid = pcall(vim.api.nvim_buf_is_valid, bufnr) + if valid_ok and is_valid then + local buftype_ok, buftype = pcall(vim.api.nvim_get_option_value, "buftype", { buf = bufnr }) + if buftype_ok and buftype == "terminal" then + local job_ok, job_id = pcall(vim.api.nvim_buf_get_var, bufnr, "terminal_job_id") + if job_ok and job_id and not tracked_pids[job_id] then + local pid_ok, pid = pcall(vim.fn.jobpid, job_id) + if pid_ok and pid and pid > 0 then + tracked_pids[job_id] = pid + logger.debug("terminal", "Recovered PID " .. pid .. " from terminal buffer " .. bufnr) + end + end + end + end + end + end + + -- Collect PIDs and job IDs first (don't stop jobs yet - that's the race condition!) + local pids_to_kill = {} + local job_ids_to_stop = {} + + for job_id, pid in pairs(tracked_pids) do + if pid and pid > 0 then + table.insert(pids_to_kill, pid) + end + table.insert(job_ids_to_stop, job_id) + end + + -- DEBUG: Write to file so we can see what happens after Neovim exits + local debug_file = io.open("/tmp/claudecode_cleanup_debug.log", "a") + if debug_file then + debug_file:write( + os.date() .. " cleanup_all: strategy=" .. strategy .. ", pids=" .. table.concat(pids_to_kill, ",") .. "\n" + ) + debug_file:close() + end + + logger.debug("terminal", "cleanup_all: strategy=" .. strategy .. ", found " .. #pids_to_kill .. " PIDs") + + -- Handle "none" strategy - don't kill anything + if strategy == "none" then + logger.debug("terminal", "cleanup_all: strategy=none, skipping process cleanup") + -- Clear tracking but don't kill + tracked_pids = {} + _G._claudecode_tracked_pids = tracked_pids + return + end + + -- For pkill_children strategy: kill children FIRST to fix race condition + -- This must happen BEFORE jobstop(), otherwise the shell is killed before children + if strategy == "pkill_children" and #pids_to_kill > 0 then + local kill_cmds = {} + for _, pid in ipairs(pids_to_kill) do + -- Kill the entire process tree recursively, not just direct children + -- 1. First, try to kill by process group (catches all descendants) + table.insert(kill_cmds, "kill -TERM -" .. pid .. " 2>/dev/null") + -- 2. Kill direct children + table.insert(kill_cmds, "pkill -TERM -P " .. pid .. " 2>/dev/null") + -- 3. Kill the shell process itself + table.insert(kill_cmds, "kill -TERM " .. pid .. " 2>/dev/null") + end + local cmd = table.concat(kill_cmds, "; ") .. "; true" + + debug_file = io.open("/tmp/claudecode_cleanup_debug.log", "a") + if debug_file then + debug_file:write(os.date() .. " pkill_children command: " .. cmd .. "\n") + debug_file:close() + end + + vim.fn.system(cmd) + + -- Give processes time to die gracefully + vim.fn.system("sleep 0.1") + + -- Second pass: kill any survivors with SIGKILL + local kill9_cmds = {} + for _, pid in ipairs(pids_to_kill) do + -- Kill entire process group with SIGKILL + table.insert(kill9_cmds, "kill -KILL -" .. pid .. " 2>/dev/null") + -- Kill remaining children with SIGKILL + table.insert(kill9_cmds, "pkill -KILL -P " .. pid .. " 2>/dev/null") + -- Kill the process itself with SIGKILL + table.insert(kill9_cmds, "kill -KILL " .. pid .. " 2>/dev/null") + end + local cmd9 = table.concat(kill9_cmds, "; ") .. "; true" + + debug_file = io.open("/tmp/claudecode_cleanup_debug.log", "a") + if debug_file then + debug_file:write(os.date() .. " SIGKILL followup: " .. cmd9 .. "\n") + debug_file:close() + end + + vim.fn.system(cmd9) + logger.debug("terminal", "cleanup_all: killed process trees of PIDs: " .. table.concat(pids_to_kill, ", ")) + end + + -- For aggressive strategy: use SIGKILL for guaranteed termination + if strategy == "aggressive" and #pids_to_kill > 0 then + local kill_cmds = {} + for _, pid in ipairs(pids_to_kill) do + -- Kill children with SIGKILL + table.insert(kill_cmds, "pkill -KILL -P " .. pid) + -- Kill the process itself with SIGKILL + table.insert(kill_cmds, "kill -KILL " .. pid) + end + local cmd = table.concat(kill_cmds, "; ") .. "; true" + + debug_file = io.open("/tmp/claudecode_cleanup_debug.log", "a") + if debug_file then + debug_file:write(os.date() .. " aggressive kill command: " .. cmd .. "\n") + debug_file:close() + end + + vim.fn.system(cmd) + logger.debug("terminal", "cleanup_all: aggressively killed PIDs: " .. table.concat(pids_to_kill, ", ")) + end + + -- Stop jobs via Neovim API (all strategies except "none") + for _, job_id in ipairs(job_ids_to_stop) do + pcall(vim.fn.jobstop, job_id) + end + + -- Clear tracked PIDs (update both local and global) + tracked_pids = {} + _G._claudecode_tracked_pids = tracked_pids +end + return M diff --git a/lua/claudecode/terminal/external.lua b/lua/claudecode/terminal/external.lua index 8ac226e8..2d95cf64 100644 --- a/lua/claudecode/terminal/external.lua +++ b/lua/claudecode/terminal/external.lua @@ -137,11 +137,24 @@ function M.open(cmd_string, env_table) cleanup_state() return end + + -- Track PID for cleanup on Neovim exit + local terminal_ok, terminal_module = pcall(require, "claudecode.terminal") + if terminal_ok and terminal_module.track_terminal_pid then + terminal_module.track_terminal_pid(jobid) + logger.debug("terminal", "Tracked external terminal PID for job_id: " .. tostring(jobid)) + end end function M.close() if is_valid() then - -- Try to stop the job gracefully + -- Kill child processes first (Fix 2: same pattern as native/snacks) + -- Shell wrappers like fish don't forward SIGTERM to child processes + local pid_ok, pid = pcall(vim.fn.jobpid, jobid) + if pid_ok and pid and pid > 0 then + pcall(vim.fn.system, "pkill -TERM -P " .. pid .. " 2>/dev/null") + end + -- Then stop the job gracefully vim.fn.jobstop(jobid) cleanup_state() end diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 7cd24dd5..970beba6 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -1,83 +1,86 @@ ---Native Neovim terminal provider for Claude Code. +---Supports multiple terminal sessions. +---Buffer-only management - window_manager handles all window operations. ---@module 'claudecode.terminal.native' local M = {} local logger = require("claudecode.logger") +local osc_handler = require("claudecode.terminal.osc_handler") +local session_manager = require("claudecode.session") local utils = require("claudecode.utils") +-- Legacy single terminal support (backward compatibility) local bufnr = nil -local winid = nil local jobid = nil local tip_shown = false +-- Multi-session terminal storage +---@class NativeTerminalState +---@field bufnr number|nil +---@field jobid number|nil + +---@type table Map of session_id -> terminal state +local terminals = {} + ---@type ClaudeCodeTerminalConfig local config = require("claudecode.terminal").defaults local function cleanup_state() bufnr = nil - winid = nil jobid = nil end local function is_valid() - -- First check if we have a valid buffer if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then cleanup_state() return false end - - -- If buffer is valid but window is invalid, try to find a window displaying this buffer - if not winid or not vim.api.nvim_win_is_valid(winid) then - -- Search all windows for our terminal buffer - local windows = vim.api.nvim_list_wins() - for _, win in ipairs(windows) do - if vim.api.nvim_win_get_buf(win) == bufnr then - -- Found a window displaying our terminal buffer, update the tracked window ID - winid = win - logger.debug("terminal", "Recovered terminal window ID:", win) - return true - end - end - -- Buffer exists but no window displays it - this is normal for hidden terminals - return true -- Buffer is valid even though not visible - end - - -- Both buffer and window are valid return true end -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 - 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 +---Create a new terminal buffer (without window) +---@param cmd_string string Command to run +---@param env_table table Environment variables +---@param effective_config ClaudeCodeTerminalConfig Terminal configuration +---@param session_id string|nil Session ID for exit handler +---@return number|nil bufnr The buffer number or nil on failure +---@return number|nil jobid The job ID or nil on failure +local function create_terminal_buffer(cmd_string, env_table, effective_config, session_id) + local window_manager = require("claudecode.terminal.window_manager") + + -- Create a new buffer + local new_bufnr = vim.api.nvim_create_buf(false, true) + if not new_bufnr or new_bufnr == 0 then + return nil, nil end - local original_win = vim.api.nvim_get_current_win() - local width = math.floor(vim.o.columns * effective_config.split_width_percentage) - local full_height = vim.o.lines - local placement_modifier - - if effective_config.split_side == "left" then - placement_modifier = "topleft " - else - placement_modifier = "botright " - end - - vim.cmd(placement_modifier .. width .. "vsplit") - local new_winid = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_height(new_winid, full_height) - - vim.api.nvim_win_call(new_winid, function() - vim.cmd("enew") - end) + vim.bo[new_bufnr].bufhidden = "hide" + + -- Prevent mouse scroll from escaping to terminal scrollback + vim.keymap.set("t", "", "", { buffer = new_bufnr, silent = true }) + vim.keymap.set("t", "", "", { buffer = new_bufnr, silent = true }) + + -- Set up BufUnload autocmd to ensure job is stopped when buffer is deleted + -- This catches :bd!, Neovim exit, and any other buffer deletion path + -- that bypasses close_session() + vim.api.nvim_create_autocmd("BufUnload", { + buffer = new_bufnr, + once = true, + callback = function() + -- Get job ID from buffer variable (set by termopen) + local ok, job_id = pcall(vim.api.nvim_buf_get_var, new_bufnr, "terminal_job_id") + if ok and job_id then + -- Get Unix PID from Neovim job ID + local pid_ok, pid = pcall(vim.fn.jobpid, job_id) + if pid_ok and pid and pid > 0 then + -- Kill child processes first (shell wrappers like fish don't forward SIGTERM) + pcall(vim.fn.system, "pkill -TERM -P " .. pid .. " 2>/dev/null") + end + pcall(vim.fn.jobstop, job_id) + end + end, + }) local term_cmd_arg if cmd_string:find(" ", 1, true) then @@ -86,187 +89,152 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) term_cmd_arg = { cmd_string } end - jobid = vim.fn.termopen(term_cmd_arg, { - env = env_table, - cwd = effective_config.cwd, - on_exit = function(job_id, _, _) - vim.schedule(function() - if job_id == jobid then - logger.debug("terminal", "Terminal process exited, cleaning up") - - -- Ensure we are operating on the correct window and buffer before closing - local current_winid_for_job = winid - local current_bufnr_for_job = bufnr - - cleanup_state() -- Clear our managed state first - - if not effective_config.auto_close then - return - end - - if current_winid_for_job and vim.api.nvim_win_is_valid(current_winid_for_job) then - if current_bufnr_for_job and vim.api.nvim_buf_is_valid(current_bufnr_for_job) then - -- Optional: Check if the window still holds the same terminal buffer - if vim.api.nvim_win_get_buf(current_winid_for_job) == current_bufnr_for_job then - vim.api.nvim_win_close(current_winid_for_job, true) + -- Open terminal in the buffer + local new_jobid + vim.api.nvim_buf_call(new_bufnr, function() + new_jobid = vim.fn.termopen(term_cmd_arg, { + env = env_table, + cwd = effective_config.cwd, + on_exit = function(job_id, _, _) + vim.schedule(function() + -- NOTE: We intentionally do NOT call untrack_terminal_pid() here. + -- This is Fix 4's "Secondary Issue" - untracking here causes a race condition + -- where PIDs are removed from tracking before cleanup_all() runs on Neovim exit. + -- Let cleanup_all() handle the cleanup of tracked_pids instead. + + -- For multi-session + if session_id then + local state = terminals[session_id] + if state and job_id == state.jobid then + logger.debug("terminal", "Terminal process exited for session: " .. session_id) + + local current_bufnr = state.bufnr + + -- Cleanup OSC handler + if current_bufnr then + osc_handler.cleanup_buffer_handler(current_bufnr) end - else - -- Buffer is invalid, but window might still be there (e.g. if user changed buffer in term window) - -- Still try to close the window we tracked. - vim.api.nvim_win_close(current_winid_for_job, true) - end - end - end - end) - end, - }) - - if not jobid or jobid == 0 then - vim.notify("Failed to open native terminal.", vim.log.levels.ERROR) - vim.api.nvim_win_close(new_winid, true) - vim.api.nvim_set_current_win(original_win) - cleanup_state() - return false - end - winid = new_winid - bufnr = vim.api.nvim_get_current_buf() - vim.bo[bufnr].bufhidden = "hide" - -- buftype=terminal is set by termopen - - if focus then - -- Focus the terminal: switch to terminal window and enter insert mode - vim.api.nvim_set_current_win(winid) - vim.cmd("startinsert") - else - -- 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) - tip_shown = true - end - return true -end + local session_count = session_manager.get_session_count() + terminals[session_id] = nil -local function close_terminal() - if is_valid() then - -- Closing the window should trigger on_exit of the job if the process is still running, - -- which then calls cleanup_state. - -- If the job already exited, on_exit would have cleaned up. - -- This direct close is for user-initiated close. - vim.api.nvim_win_close(winid, true) - cleanup_state() -- Cleanup after explicit close - end -end + if session_manager.get_session(session_id) then + session_manager.destroy_session(session_id) + end -local function focus_terminal() - if is_valid() then - vim.api.nvim_set_current_win(winid) - vim.cmd("startinsert") - end -end + if not effective_config.auto_close then + return + end -local function is_terminal_visible() - -- Check if our terminal buffer exists and is displayed in any window - if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then - return false - end + -- If there are other sessions, switch to the new active session + if session_count > 1 then + local new_active_id = session_manager.get_active_session_id() + if new_active_id then + local new_state = terminals[new_active_id] + if new_state and new_state.bufnr and vim.api.nvim_buf_is_valid(new_state.bufnr) then + window_manager.display_buffer(new_state.bufnr, true) + + -- Update legacy state + bufnr = new_state.bufnr + jobid = new_state.jobid + + -- Re-attach tabbar + local winid = window_manager.get_window() + if winid then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, new_state.bufnr) + end + end + + -- Delete old buffer + if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then + vim.api.nvim_buf_delete(current_bufnr, { force = true }) + end + return + end + end + end - local windows = vim.api.nvim_list_wins() - for _, win in ipairs(windows) do - if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then - -- Update our tracked window ID if we find the buffer in a different window - winid = win - return true - end - end + -- No other sessions, close the window + window_manager.close_window() + if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then + vim.api.nvim_buf_delete(current_bufnr, { force = true }) + end + end + else + -- Legacy terminal exit handling + if job_id == jobid then + logger.debug("terminal", "Terminal process exited, cleaning up") - -- Buffer exists but no window displays it - winid = nil - return false -end + local current_bufnr = bufnr -local function hide_terminal() - -- Hide the terminal window but keep the buffer and job alive - if bufnr and vim.api.nvim_buf_is_valid(bufnr) and winid and vim.api.nvim_win_is_valid(winid) then - -- Close the window - this preserves the buffer and job - vim.api.nvim_win_close(winid, false) - winid = nil -- Clear window reference + if current_bufnr then + osc_handler.cleanup_buffer_handler(current_bufnr) + end - logger.debug("terminal", "Terminal window hidden, process preserved") - end -end + local session_count = session_manager.get_session_count() + local session = session_manager.find_session_by_bufnr(current_bufnr) + if session then + logger.debug("terminal", "Destroying session for exited terminal: " .. session.id) + if session_manager.get_session(session.id) then + session_manager.destroy_session(session.id) + end + end -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 - end + cleanup_state() - -- Check if it's already visible - if is_terminal_visible() then - if focus then - focus_terminal() - end - return true - end + if not effective_config.auto_close then + return + end - local original_win = vim.api.nvim_get_current_win() + -- If there are other sessions, switch to one + if session_count > 1 then + local new_active_id = session_manager.get_active_session_id() + if new_active_id then + local new_state = terminals[new_active_id] + if new_state and new_state.bufnr and vim.api.nvim_buf_is_valid(new_state.bufnr) then + window_manager.display_buffer(new_state.bufnr, true) + + bufnr = new_state.bufnr + jobid = new_state.jobid + + local winid = window_manager.get_window() + if winid then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, new_state.bufnr) + end + end + + if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then + vim.api.nvim_buf_delete(current_bufnr, { force = true }) + end + return + end + end + end - -- 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 - local placement_modifier + window_manager.close_window() + end + end + end) + end, + }) + end) - if effective_config.split_side == "left" then - placement_modifier = "topleft " - else - placement_modifier = "botright " + if not new_jobid or new_jobid == 0 then + vim.api.nvim_buf_delete(new_bufnr, { force = true }) + return nil, nil end - vim.cmd(placement_modifier .. width .. "vsplit") - local new_winid = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_height(new_winid, full_height) - - -- Set the existing buffer in the new window - vim.api.nvim_win_set_buf(new_winid, bufnr) - winid = new_winid - - 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) + -- Track PID for cleanup on Neovim exit + local terminal_module = require("claudecode.terminal") + if terminal_module.track_terminal_pid then + terminal_module.track_terminal_pid(new_jobid) end - logger.debug("terminal", "Showed hidden terminal in new window") - return true -end - -local function find_existing_claude_terminal() - local buffers = vim.api.nvim_list_bufs() - for _, buf in ipairs(buffers) do - if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_get_option(buf, "buftype") == "terminal" then - -- Check if this is a Claude Code terminal by examining the buffer name or terminal job - local buf_name = vim.api.nvim_buf_get_name(buf) - -- Terminal buffers often have names like "term://..." that include the command - if buf_name:match("claude") then - -- Additional check: see if there's a window displaying this buffer - local windows = vim.api.nvim_list_wins() - for _, win in ipairs(windows) do - if vim.api.nvim_win_get_buf(win) == buf then - logger.debug("terminal", "Found existing Claude terminal in buffer", buf, "window", win) - return buf, win - end - end - end - end - end - return nil, nil + return new_bufnr, new_jobid end ---Setup the terminal module @@ -280,42 +248,70 @@ end --- @param effective_config table --- @param focus boolean|nil function M.open(cmd_string, env_table, effective_config, focus) + local window_manager = require("claudecode.terminal.window_manager") focus = utils.normalize_focus(focus) if is_valid() then - -- 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() - if existing_buf and existing_win then - -- Recover the existing terminal - bufnr = existing_buf - winid = existing_win - -- Note: We can't recover the job ID easily, but it's less critical - logger.debug("terminal", "Recovered existing Claude terminal") - if focus then - focus_terminal() -- Focus recovered terminal - end - -- If focus=false, preserve user context by staying in current window - else - if not open_terminal(cmd_string, env_table, effective_config, focus) then - vim.notify("Failed to open Claude terminal using native fallback.", vim.log.levels.ERROR) - end + -- Terminal buffer exists, display it via window manager + window_manager.display_buffer(bufnr, focus) + return + end + + -- Ensure a session exists + local session_id = session_manager.ensure_session() + + -- Create terminal buffer + local new_bufnr, new_jobid = create_terminal_buffer(cmd_string, env_table, effective_config, nil) + if not new_bufnr then + vim.notify("Failed to open Claude terminal using native fallback.", vim.log.levels.ERROR) + return + end + + bufnr = new_bufnr + jobid = new_jobid + + -- Display buffer via window manager + window_manager.display_buffer(bufnr, focus) + + -- Set up terminal keymaps + local terminal_module = require("claudecode.terminal") + terminal_module.setup_terminal_keymaps(bufnr, config) + + -- Update session info + session_manager.update_terminal_info(session_id, { + bufnr = bufnr, + winid = window_manager.get_window(), + jobid = jobid, + }) + + -- Register buffer-to-session mapping for cleanup on BufUnload (Fix 1) + terminal_module.register_buffer_session(bufnr, session_id) + + -- Also register in terminals table + terminals[session_id] = { + bufnr = bufnr, + jobid = jobid, + } + + -- Attach tabbar + local winid = window_manager.get_window() + if winid then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, bufnr) end end + + if config.show_native_term_exit_tip and not tip_shown then + local exit_key = config.keymaps and config.keymaps.exit_terminal or "Ctrl-\\ Ctrl-N" + vim.notify("Native terminal opened. Press " .. exit_key .. " to return to Normal mode.", vim.log.levels.INFO) + tip_shown = true + end end function M.close() - close_terminal() + local window_manager = require("claudecode.terminal.window_manager") + window_manager.close_window() end ---Simple toggle: always show/hide terminal regardless of focus @@ -323,38 +319,20 @@ end ---@param env_table table ---@param effective_config ClaudeCodeTerminalConfig function M.simple_toggle(cmd_string, env_table, effective_config) - -- Check if we have a valid terminal buffer (process running) - local has_buffer = bufnr and vim.api.nvim_buf_is_valid(bufnr) - local is_visible = has_buffer and is_terminal_visible() - - if is_visible then - -- Terminal is visible, hide it (but keep process running) - hide_terminal() + local window_manager = require("claudecode.terminal.window_manager") + + if window_manager.is_visible() then + -- Terminal is visible, hide it + logger.debug("terminal", "Simple toggle: hiding terminal") + window_manager.close_window() + elseif is_valid() then + -- Terminal buffer exists but not visible, show it + logger.debug("terminal", "Simple toggle: showing hidden terminal") + window_manager.display_buffer(bufnr, true) else - -- Terminal is not visible - if has_buffer then - -- Terminal process exists but is hidden, show it - if show_hidden_terminal(effective_config, true) then - logger.debug("terminal", "Showing hidden terminal") - else - logger.error("terminal", "Failed to show hidden terminal") - end - else - -- No terminal process exists, check if there's an existing one we lost track of - local existing_buf, existing_win = find_existing_claude_terminal() - if existing_buf and existing_win then - -- Recover the existing terminal - bufnr = existing_buf - winid = existing_win - logger.debug("terminal", "Recovered existing Claude terminal") - focus_terminal() - else - -- No existing terminal found, create a new one - if not open_terminal(cmd_string, env_table, effective_config) then - vim.notify("Failed to open Claude terminal using native fallback (simple_toggle).", vim.log.levels.ERROR) - end - end - end + -- No terminal exists, create new one + logger.debug("terminal", "Simple toggle: creating new terminal") + M.open(cmd_string, env_table, effective_config) end end @@ -363,53 +341,31 @@ end ---@param env_table table ---@param effective_config ClaudeCodeTerminalConfig function M.focus_toggle(cmd_string, env_table, effective_config) - -- Check if we have a valid terminal buffer (process running) - local has_buffer = bufnr and vim.api.nvim_buf_is_valid(bufnr) - local is_visible = has_buffer and is_terminal_visible() - - if has_buffer then - -- Terminal process exists - if is_visible then - -- Terminal is visible - check if we're currently in it - local current_win_id = vim.api.nvim_get_current_win() - if winid == current_win_id then - -- We're in the terminal window, hide it (but keep process running) - hide_terminal() - else - -- Terminal is visible but we're not in it, focus it - focus_terminal() - end + local window_manager = require("claudecode.terminal.window_manager") + + if not window_manager.is_visible() then + -- Terminal not visible + if is_valid() then + logger.debug("terminal", "Focus toggle: showing hidden terminal") + window_manager.display_buffer(bufnr, true) else - -- Terminal process exists but is hidden, show it - if show_hidden_terminal(effective_config, true) then - logger.debug("terminal", "Showing hidden terminal") - else - logger.error("terminal", "Failed to show hidden terminal") - end + logger.debug("terminal", "Focus toggle: creating new terminal") + M.open(cmd_string, env_table, effective_config) end else - -- No terminal process exists, check if there's an existing one we lost track of - local existing_buf, existing_win = find_existing_claude_terminal() - if existing_buf and existing_win then - -- Recover the existing terminal - bufnr = existing_buf - winid = existing_win - logger.debug("terminal", "Recovered existing Claude terminal") - - -- Check if we're currently in this recovered terminal - local current_win_id = vim.api.nvim_get_current_win() - if existing_win == current_win_id then - -- We're in the recovered terminal, hide it - hide_terminal() - else - -- Focus the recovered terminal - focus_terminal() - end + -- Terminal is visible + local winid = window_manager.get_window() + local current_win = vim.api.nvim_get_current_win() + + if winid == current_win then + -- We're focused on terminal, hide it + logger.debug("terminal", "Focus toggle: hiding terminal (currently focused)") + window_manager.close_window() else - -- No existing terminal found, create a new one - if not open_terminal(cmd_string, env_table, effective_config) then - vim.notify("Failed to open Claude terminal using native fallback (focus_toggle).", vim.log.levels.ERROR) - end + -- Terminal visible but not focused, focus it + logger.debug("terminal", "Focus toggle: focusing terminal") + vim.api.nvim_set_current_win(winid) + vim.cmd("startinsert") end end end @@ -435,5 +391,345 @@ function M.is_available() return true -- Native provider is always available end +-- ============================================================================ +-- Multi-session support functions +-- ============================================================================ + +---Helper to check if a session's terminal is valid +---@param session_id string +---@return boolean +local function is_session_valid(session_id) + local state = terminals[session_id] + if not state or not state.bufnr or not vim.api.nvim_buf_is_valid(state.bufnr) then + return false + end + return true +end + +---Open a terminal for a specific session +---@param session_id string The session ID +---@param cmd_string string The command to run +---@param env_table table Environment variables +---@param effective_config ClaudeCodeTerminalConfig Terminal configuration +---@param focus boolean? Whether to focus the terminal +function M.open_session(session_id, cmd_string, env_table, effective_config, focus) + local window_manager = require("claudecode.terminal.window_manager") + focus = utils.normalize_focus(focus) + + logger.debug("terminal", "open_session called for: " .. session_id) + + -- Check if this session already has a valid terminal buffer + if is_session_valid(session_id) then + -- Display existing buffer via window manager + window_manager.display_buffer(terminals[session_id].bufnr, focus) + + -- Update legacy state + bufnr = terminals[session_id].bufnr + jobid = terminals[session_id].jobid + + -- Re-attach tabbar + local winid = window_manager.get_window() + if winid then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, terminals[session_id].bufnr) + end + end + + logger.debug("terminal", "Displayed existing terminal for session: " .. session_id) + return + end + + -- Create new terminal buffer for this session + local new_bufnr, new_jobid = create_terminal_buffer(cmd_string, env_table, effective_config, session_id) + if not new_bufnr then + vim.notify("Failed to open native terminal for session: " .. session_id, vim.log.levels.ERROR) + return + end + + -- Display buffer via window manager + window_manager.display_buffer(new_bufnr, focus) + + -- Set up terminal keymaps + local terminal_module = require("claudecode.terminal") + terminal_module.setup_terminal_keymaps(new_bufnr, config) + + -- Store session state + terminals[session_id] = { + bufnr = new_bufnr, + jobid = new_jobid, + } + + -- Update legacy state + bufnr = new_bufnr + jobid = new_jobid + + -- Update session manager + terminal_module.update_session_terminal_info(session_id, { + bufnr = new_bufnr, + winid = window_manager.get_window(), + jobid = new_jobid, + }) + + -- Register buffer-to-session mapping for cleanup on BufUnload (Fix 1) + terminal_module.register_buffer_session(new_bufnr, session_id) + + -- Setup OSC title handler + osc_handler.setup_buffer_handler(new_bufnr, function(title) + if title and title ~= "" then + session_manager.update_session_name(session_id, title) + end + end) + + -- Attach tabbar + local winid = window_manager.get_window() + if winid then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, new_bufnr) + end + end + + if config.show_native_term_exit_tip and not tip_shown then + local exit_key = config.keymaps and config.keymaps.exit_terminal or "Ctrl-\\ Ctrl-N" + vim.notify("Native terminal opened. Press " .. exit_key .. " to return to Normal mode.", vim.log.levels.INFO) + tip_shown = true + end + + logger.debug("terminal", "Opened terminal for session: " .. session_id) +end + +---Close a terminal for a specific session +---@param session_id string The session ID +function M.close_session(session_id) + local state = terminals[session_id] + if not state then + return + end + + -- Stop the job first to ensure the process is terminated + if state.jobid then + -- Get Unix PID from Neovim job ID + local pid_ok, pid = pcall(vim.fn.jobpid, state.jobid) + if pid_ok and pid and pid > 0 then + -- Kill child processes first (shell wrappers like fish don't forward SIGTERM) + pcall(vim.fn.system, "pkill -TERM -P " .. pid .. " 2>/dev/null") + end + -- Then kill the shell process + pcall(vim.fn.jobstop, state.jobid) + end + + -- Clean up the buffer if it exists + if state.bufnr and vim.api.nvim_buf_is_valid(state.bufnr) then + osc_handler.cleanup_buffer_handler(state.bufnr) + vim.api.nvim_buf_delete(state.bufnr, { force = true }) + end + + terminals[session_id] = nil + + -- If this was the legacy terminal, clear it too + if bufnr == state.bufnr then + cleanup_state() + end +end + +---Close a session's terminal but keep window open and switch to another session +---@param old_session_id string The session ID to close +---@param new_session_id string The session ID to switch to +---@param effective_config ClaudeCodeTerminalConfig Terminal configuration +function M.close_session_keep_window(old_session_id, new_session_id, effective_config) + local window_manager = require("claudecode.terminal.window_manager") + + local old_state = terminals[old_session_id] + local new_state = terminals[new_session_id] + + -- Try to recover new_state from session_manager if not in terminals table + if not new_state or not new_state.bufnr or not vim.api.nvim_buf_is_valid(new_state.bufnr) then + local session_data = session_manager.get_session(new_session_id) + if session_data and session_data.terminal_bufnr and vim.api.nvim_buf_is_valid(session_data.terminal_bufnr) then + if bufnr and bufnr == session_data.terminal_bufnr then + new_state = { + bufnr = bufnr, + jobid = jobid, + } + terminals[new_session_id] = new_state + logger.debug("terminal", "Recovered legacy terminal for new session: " .. new_session_id) + end + end + end + + -- Try to recover old_state from legacy terminal + if not old_state then + local old_session_data = session_manager.get_session(old_session_id) + if + old_session_data + and old_session_data.terminal_bufnr + and vim.api.nvim_buf_is_valid(old_session_data.terminal_bufnr) + then + if bufnr and bufnr == old_session_data.terminal_bufnr then + old_state = { + bufnr = bufnr, + jobid = jobid, + } + logger.debug("terminal", "Using legacy terminal as old_state for: " .. old_session_id) + end + end + if not old_state then + logger.debug("terminal", "No terminal found for old session: " .. old_session_id) + return + end + end + + -- If new terminal exists, display it via window manager + if new_state and new_state.bufnr and vim.api.nvim_buf_is_valid(new_state.bufnr) then + window_manager.display_buffer(new_state.bufnr, true) + + -- Update legacy state + bufnr = new_state.bufnr + jobid = new_state.jobid + + -- Update tabbar + local winid = window_manager.get_window() + if winid then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, new_state.bufnr) + end + end + else + -- No valid new_state found - close the window + logger.warn("terminal", "No valid terminal found for new session: " .. new_session_id) + window_manager.close_window() + end + + -- Stop the old job first to ensure the process is terminated + if old_state and old_state.jobid then + -- Get Unix PID from Neovim job ID + local pid_ok, pid = pcall(vim.fn.jobpid, old_state.jobid) + if pid_ok and pid and pid > 0 then + -- Kill child processes first (shell wrappers like fish don't forward SIGTERM) + pcall(vim.fn.system, "pkill -TERM -P " .. pid .. " 2>/dev/null") + end + -- Then kill the shell process + pcall(vim.fn.jobstop, old_state.jobid) + end + + -- Clean up old terminal's buffer + if old_state and old_state.bufnr and vim.api.nvim_buf_is_valid(old_state.bufnr) then + osc_handler.cleanup_buffer_handler(old_state.bufnr) + vim.api.nvim_buf_delete(old_state.bufnr, { force = true }) + end + + terminals[old_session_id] = nil + + logger.debug("terminal", "Closed session " .. old_session_id .. " and switched to " .. new_session_id) +end + +---Focus a terminal for a specific session +---@param session_id string The session ID +---@param effective_config ClaudeCodeTerminalConfig|nil Terminal configuration +function M.focus_session(session_id, effective_config) + local window_manager = require("claudecode.terminal.window_manager") + + -- Check if session is valid in terminals table + if not is_session_valid(session_id) then + -- Fallback: Check if legacy terminal matches the session's bufnr + local session_mod = require("claudecode.session") + local session = session_mod.get_session(session_id) + if session and session.terminal_bufnr and bufnr and bufnr == session.terminal_bufnr then + logger.debug("terminal", "Registering legacy terminal for session: " .. session_id) + M.register_terminal_for_session(session_id, bufnr) + else + logger.debug("terminal", "Cannot focus invalid session: " .. session_id) + return + end + end + + local state = terminals[session_id] + if not state or not state.bufnr or not vim.api.nvim_buf_is_valid(state.bufnr) then + logger.debug("terminal", "Session has no valid buffer: " .. session_id) + return + end + + -- Display buffer via window manager + window_manager.display_buffer(state.bufnr, true) + + -- Update legacy state + bufnr = state.bufnr + jobid = state.jobid + + -- Re-attach tabbar + local winid = window_manager.get_window() + if winid then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, state.bufnr) + end + end + + logger.debug("terminal", "Focused session: " .. session_id) +end + +---Get the buffer number for a session's terminal +---@param session_id string The session ID +---@return number|nil bufnr The buffer number or nil +function M.get_session_bufnr(session_id) + local state = terminals[session_id] + if state and state.bufnr and vim.api.nvim_buf_is_valid(state.bufnr) then + return state.bufnr + end + return nil +end + +---Get all session IDs with active terminals +---@return string[] session_ids Array of session IDs +function M.get_active_session_ids() + local ids = {} + for session_id, state in pairs(terminals) do + if state and state.bufnr and vim.api.nvim_buf_is_valid(state.bufnr) then + table.insert(ids, session_id) + end + end + return ids +end + +---Register an existing terminal (from legacy path) with a session ID +---@param session_id string The session ID +---@param term_bufnr number|nil The buffer number (uses legacy bufnr if nil) +function M.register_terminal_for_session(session_id, term_bufnr) + term_bufnr = term_bufnr or bufnr + + if not term_bufnr or not vim.api.nvim_buf_is_valid(term_bufnr) then + logger.debug("terminal", "Cannot register invalid terminal for session: " .. session_id) + return + end + + -- Check if this terminal is already registered to another session + for sid, state in pairs(terminals) do + if state and state.bufnr == term_bufnr and sid ~= session_id then + logger.debug( + "terminal", + "Terminal already registered to session " .. sid .. ", not registering to " .. session_id + ) + return + end + end + + -- Check if this session already has a different terminal + local existing_state = terminals[session_id] + if existing_state and existing_state.bufnr and existing_state.bufnr ~= term_bufnr then + logger.debug("terminal", "Session " .. session_id .. " already has a different terminal") + return + end + + -- Register the legacy terminal with the session + terminals[session_id] = { + bufnr = term_bufnr, + jobid = jobid, + } + + logger.debug("terminal", "Registered terminal (bufnr=" .. term_bufnr .. ") for session: " .. session_id) +end + --- @type ClaudeCodeTerminalProvider return M diff --git a/lua/claudecode/terminal/osc_handler.lua b/lua/claudecode/terminal/osc_handler.lua new file mode 100644 index 00000000..c71bfc46 --- /dev/null +++ b/lua/claudecode/terminal/osc_handler.lua @@ -0,0 +1,239 @@ +---Terminal title watcher for session naming. +---Watches vim.b.term_title to capture terminal title set by Claude CLI. +---@module 'claudecode.terminal.osc_handler' + +local M = {} + +local logger = require("claudecode.logger") + +-- Storage for buffer handlers +---@type table +local handlers = {} + +-- Timer interval in milliseconds +local POLL_INTERVAL_MS = 2000 +local INITIAL_DELAY_MS = 500 + +---Strip common prefixes from title (like "Claude - ") +---@param title string The raw title +---@return string title The cleaned title +function M.clean_title(title) + if not title then + return title + end + + -- Strip "Claude - " prefix (case insensitive) + title = title:gsub("^[Cc]laude %- ", "") + + -- Strip leading/trailing whitespace + title = title:gsub("^%s+", ""):gsub("%s+$", "") + + -- Limit length to prevent issues + if #title > 100 then + title = title:sub(1, 97) .. "..." + end + + return title +end + +---Setup title watcher for a terminal buffer +---Watches vim.b.term_title for changes and calls callback when title changes +---@param bufnr number The terminal buffer number +---@param callback function Called with (title: string) when title changes +function M.setup_buffer_handler(bufnr, callback) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + logger.warn("osc_handler", "Cannot setup handler for invalid buffer") + return + end + + -- Clean up existing handler if any + M.cleanup_buffer_handler(bufnr) + + -- Create autocommand group for this buffer + local augroup = vim.api.nvim_create_augroup("ClaudeCodeTitle_" .. bufnr, { clear = true }) + + -- Store handler info with last_title for change detection + handlers[bufnr] = { + augroup = augroup, + timer = nil, + callback = callback, + last_title = nil, + } + + ---Check title and call callback if changed + local function check_title() + local handler = handlers[bufnr] + if not handler then + return + end + + if not vim.api.nvim_buf_is_valid(bufnr) then + M.cleanup_buffer_handler(bufnr) + return + end + + -- Read term_title from buffer + local current_title = vim.b[bufnr].term_title + if not current_title or current_title == "" then + return + end + + -- Check if title changed + if current_title == handler.last_title then + return + end + + handler.last_title = current_title + + -- Clean the title + local cleaned = M.clean_title(current_title) + if not cleaned or cleaned == "" then + return + end + + logger.debug("osc_handler", "Terminal title changed: " .. cleaned) + + -- Call the callback + if handler.callback then + handler.callback(cleaned) + end + end + + -- Check on TermEnter (when user enters terminal) + vim.api.nvim_create_autocmd("TermEnter", { + group = augroup, + buffer = bufnr, + callback = function() + vim.schedule(check_title) + end, + desc = "Claude Code terminal title check on enter", + }) + + -- Check on BufEnter as well (sometimes TermEnter doesn't fire) + vim.api.nvim_create_autocmd("BufEnter", { + group = augroup, + buffer = bufnr, + callback = function() + vim.schedule(check_title) + end, + desc = "Claude Code terminal title check on buffer enter", + }) + + -- Also poll periodically for background title updates + local timer = vim.loop.new_timer() + if timer then + timer:start( + INITIAL_DELAY_MS, + POLL_INTERVAL_MS, + vim.schedule_wrap(function() + -- Check if handler still exists and buffer is valid + if handlers[bufnr] and vim.api.nvim_buf_is_valid(bufnr) then + check_title() + else + -- Stop timer if buffer is gone + if timer and not timer:is_closing() then + timer:stop() + timer:close() + end + end + end) + ) + handlers[bufnr].timer = timer + end + + logger.debug("osc_handler", "Setup title watcher for buffer " .. bufnr) +end + +---Cleanup title watcher for a buffer +---@param bufnr number The terminal buffer number +function M.cleanup_buffer_handler(bufnr) + local handler = handlers[bufnr] + if not handler then + return + end + + -- Stop and close the timer + if handler.timer then + if not handler.timer:is_closing() then + handler.timer:stop() + handler.timer:close() + end + handler.timer = nil + end + + -- Delete the autocommand group + pcall(vim.api.nvim_del_augroup_by_id, handler.augroup) + + -- Remove from storage + handlers[bufnr] = nil + + logger.debug("osc_handler", "Cleaned up title watcher for buffer " .. bufnr) +end + +---Check if a buffer has a title watcher registered +---@param bufnr number The buffer number +---@return boolean +function M.has_handler(bufnr) + return handlers[bufnr] ~= nil +end + +---Get handler count (for testing) +---@return number +function M._get_handler_count() + local count = 0 + for _ in pairs(handlers) do + count = count + 1 + end + return count +end + +---Reset all handlers (for testing) +function M._reset() + for bufnr, _ in pairs(handlers) do + M.cleanup_buffer_handler(bufnr) + end + handlers = {} +end + +-- Keep parse_osc_title for backwards compatibility and testing +-- even though we no longer use TermRequest + +---Parse OSC title from escape sequence data (legacy, kept for testing) +---Handles OSC 0 (icon + title) and OSC 2 (title only) +---Format: ESC ] Ps ; Pt BEL or ESC ] Ps ; Pt ST +---@param data string The raw escape sequence data +---@return string|nil title The extracted title, or nil if not a title sequence +function M.parse_osc_title(data) + if not data or data == "" then + return nil + end + + local _, content + + -- Pattern 1: ESC ] 0/2 ; title BEL + _, content = data:match("^\027%]([02]);(.-)\007$") + if content then + content = content:gsub("^%s+", ""):gsub("%s+$", "") + return content ~= "" and content or nil + end + + -- Pattern 2: ESC ] 0/2 ; title ST (ESC \) + _, content = data:match("^\027%]([02]);(.-)\027\\$") + if content then + content = content:gsub("^%s+", ""):gsub("%s+$", "") + return content ~= "" and content or nil + end + + -- Pattern 3: ] 0/2 ; title (ESC prefix already stripped) + _, content = data:match("^%]([02]);(.-)$") + if content then + -- Remove any trailing control characters + content = content:gsub("[\007\027%z\\].*$", "") + content = content:gsub("^%s+", ""):gsub("%s+$", "") + return content ~= "" and content or nil + end + + return nil +end + +return M diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 2b4c7c98..771e3088 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -1,12 +1,26 @@ ---Snacks.nvim terminal provider for Claude Code. +---Supports multiple terminal sessions. +---Buffer-only management - window_manager handles all window operations. ---@module 'claudecode.terminal.snacks' local M = {} local snacks_available, Snacks = pcall(require, "snacks") +local osc_handler = require("claudecode.terminal.osc_handler") +local session_manager = require("claudecode.session") local utils = require("claudecode.utils") + +-- Legacy single terminal support (backward compatibility) local terminal = nil +-- Multi-session terminal storage +---@type table Map of session_id -> terminal instance +local terminals = {} + +-- Track sessions being intentionally closed (to suppress exit error messages) +---@type table +local closing_sessions = {} + --- @return boolean local function is_available() return snacks_available and Snacks and Snacks.terminal ~= nil @@ -15,20 +29,131 @@ end ---Setup event handlers for terminal instance ---@param term_instance table The Snacks terminal instance ---@param config table Configuration options -local function setup_terminal_events(term_instance, config) +---@param session_id string|nil Optional session ID for multi-session support +local function setup_terminal_events(term_instance, config, session_id) local logger = require("claudecode.logger") + local window_manager = require("claudecode.terminal.window_manager") -- Handle command completion/exit - only if auto_close is enabled if config.auto_close then term_instance:on("TermClose", function() - if vim.v.event.status ~= 0 then + -- Check if this was an intentional close (via close_session_keep_window) + local is_intentional_close = session_id and closing_sessions[session_id] + + -- Only show error if this wasn't an intentional close + if vim.v.event.status ~= 0 and not is_intentional_close then logger.error("terminal", "Claude exited with code " .. vim.v.event.status .. ".\nCheck for any errors.") end - -- Clean up - terminal = nil + -- If this was an intentional close, close_session_keep_window already handled + -- the session switching - we just need minimal cleanup here + if is_intentional_close then + logger.debug("terminal", "TermClose for intentionally closed session, skipping switch logic") + if session_id then + terminals[session_id] = nil + closing_sessions[session_id] = nil + end + if terminal == term_instance then + terminal = nil + end + return + end + + -- Check if there are other sessions before destroying + local session_count = session_manager.get_session_count() + local current_bufnr = term_instance.buf + + -- Track the exited session ID for cleanup + local exited_session_id = session_id + + -- Clean up terminal state + if session_id then + terminals[session_id] = nil + closing_sessions[session_id] = nil + -- Destroy the session in session manager (only if it still exists) + if session_manager.get_session(session_id) then + session_manager.destroy_session(session_id) + end + else + -- For legacy terminal, find and destroy associated session + if term_instance.buf then + local session = session_manager.find_session_by_bufnr(term_instance.buf) + if session then + exited_session_id = session.id + logger.debug("terminal", "Destroying session for exited terminal: " .. session.id) + if session_manager.get_session(session.id) then + session_manager.destroy_session(session.id) + end + end + end + end + vim.schedule(function() - term_instance:close({ buf = true }) + -- If there are other sessions, switch to the new active session + if session_count > 1 then + local new_active_id = session_manager.get_active_session_id() + if new_active_id then + local new_term = terminals[new_active_id] + + -- Fallback: check if any other terminal in our table is valid + if not new_term or not new_term:buf_valid() then + for sid, term in pairs(terminals) do + if sid ~= exited_session_id and term and term:buf_valid() then + new_term = term + terminals[new_active_id] = new_term + logger.debug("terminal", "Recovered terminal from table for session: " .. new_active_id) + break + end + end + end + + -- Fallback: check the global terminal variable + if not new_term or not new_term:buf_valid() then + if terminal and terminal:buf_valid() and terminal ~= term_instance then + new_term = terminal + terminals[new_active_id] = new_term + logger.debug("terminal", "Recovered global terminal for session: " .. new_active_id) + end + end + + if new_term and new_term:buf_valid() and new_term.buf then + -- Display the new session's buffer in window manager's window + window_manager.display_buffer(new_term.buf, true) + + -- Update legacy terminal reference + terminal = new_term + + -- Re-attach tabbar + local winid = window_manager.get_window() + if winid then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, new_term.buf, new_term) + end + end + + logger.debug("terminal", "Switched to session " .. new_active_id) + + -- Delete the old buffer after switching + if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then + vim.api.nvim_buf_delete(current_bufnr, { force = true }) + end + + vim.cmd.checktime() + return + end + end + end + + -- No other sessions or couldn't switch, close the window + if terminal == term_instance then + terminal = nil + end + window_manager.close_window() + -- Delete the buffer + if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then + vim.api.nvim_buf_delete(current_bufnr, { force = true }) + end vim.cmd.checktime() end) end, { buf = true }) @@ -36,51 +161,243 @@ local function setup_terminal_events(term_instance, config) -- Handle buffer deletion term_instance:on("BufWipeout", function() - logger.debug("terminal", "Terminal buffer wiped") - terminal = nil + logger.debug("terminal", "Terminal buffer wiped" .. (session_id and (" for session " .. session_id) or "")) + + -- Cleanup OSC handler + if term_instance.buf then + osc_handler.cleanup_buffer_handler(term_instance.buf) + end + + if session_id then + terminals[session_id] = nil + if session_manager.get_session(session_id) then + session_manager.destroy_session(session_id) + end + else + if term_instance.buf then + local session = session_manager.find_session_by_bufnr(term_instance.buf) + if session then + logger.debug("terminal", "Destroying session for wiped terminal: " .. session.id) + if session_manager.get_session(session.id) then + session_manager.destroy_session(session.id) + end + end + end + terminal = nil + end end, { buf = true }) end ----Builds Snacks terminal options with focus control +---Track terminal PID with retry mechanism +---Snacks.terminal.open() may not set terminal_job_id immediately on the buffer. +---This function retries tracking until successful or max retries reached. +---@param term_buf number The terminal buffer number +---@param max_retries number Maximum number of retries (default 5) +---@param delay_ms number Delay between retries in milliseconds (default 50) +local function track_pid_with_retry(term_buf, max_retries, delay_ms) + max_retries = max_retries or 5 + delay_ms = delay_ms or 50 + local retries = 0 + + local function try_track() + if not term_buf or not vim.api.nvim_buf_is_valid(term_buf) then + return + end + + local ok, job_id = pcall(vim.api.nvim_buf_get_var, term_buf, "terminal_job_id") + if ok and job_id then + local terminal_module = require("claudecode.terminal") + if terminal_module.track_terminal_pid then + terminal_module.track_terminal_pid(job_id) + local logger = require("claudecode.logger") + logger.debug("terminal", "PID tracked for job_id: " .. tostring(job_id) .. " (attempt " .. (retries + 1) .. ")") + end + return + end + + retries = retries + 1 + if retries < max_retries then + vim.defer_fn(try_track, delay_ms * retries) + else + local logger = require("claudecode.logger") + logger.warn("terminal", "Failed to track PID for buffer " .. term_buf .. " after " .. max_retries .. " retries") + end + end + + -- Start the first attempt + try_track() +end + +---Build initial title for session tabs +---@param session_id string|nil Optional session ID +---@return string title The title string +local function build_initial_title(session_id) + local sm = require("claudecode.session") + local sessions = sm.list_sessions() + local active_id = session_id or sm.get_active_session_id() + + if #sessions == 0 then + return "Claude Code" + end + + local parts = {} + for i, session in ipairs(sessions) do + local is_active = session.id == active_id + local name = session.name or ("Session " .. i) + if #name > 15 then + name = name:sub(1, 12) .. "..." + end + local label = string.format("%d:%s", i, name) + if is_active then + label = "[" .. label .. "]" + end + table.insert(parts, label) + end + table.insert(parts, "[+]") + return table.concat(parts, " | ") +end + +---Builds Snacks terminal options for buffer creation (no window focus) ---@param config ClaudeCodeTerminalConfig Terminal configuration ---@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 snacks.terminal.Opts opts 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) +---@param session_id string|nil Optional session ID for title +---@return snacks.terminal.Opts opts Snacks terminal options +local function build_opts(config, env_table, session_id) + -- Build keys table with optional exit_terminal keymap + local keys = { + claude_new_line = { + "", + function() + vim.api.nvim_feedkeys("\\", "t", true) + vim.defer_fn(function() + vim.api.nvim_feedkeys("\r", "t", true) + end, 10) + end, + mode = "t", + desc = "New line", + }, + } + + -- Only add exit_terminal keymap to Snacks keys if smart ESC handling is disabled + local esc_timeout = config.esc_timeout + if (not esc_timeout or esc_timeout == 0) and config.keymaps and config.keymaps.exit_terminal then + keys.claude_exit_terminal = { + config.keymaps.exit_terminal, + "", + mode = "t", + desc = "Exit terminal mode", + } + end + + -- Build title for tabs if enabled + local title = nil + if config.tabs and config.tabs.enabled then + title = build_initial_title(session_id) + end + + -- Merge user's snacks_win_opts + local win_opts = vim.tbl_deep_extend("force", { + position = config.split_side, + width = config.split_width_percentage, + height = 0, + relative = "editor", + keys = keys, + title = title, + title_pos = title and "center" or nil, + wo = {}, + } --[[@as snacks.win.Config]], config.snacks_win_opts or {}) + return { env = env_table, cwd = config.cwd, - start_insert = focus, - auto_insert = focus, + start_insert = false, -- Don't auto-start insert, window_manager handles focus + auto_insert = false, auto_close = false, - win = vim.tbl_deep_extend("force", { - position = config.split_side, - width = config.split_width_percentage, - height = 0, - relative = "editor", - keys = { - claude_new_line = { - "", - function() - vim.api.nvim_feedkeys("\\", "t", true) - vim.defer_fn(function() - vim.api.nvim_feedkeys("\r", "t", true) - end, 10) - end, - mode = "t", - desc = "New line", - }, - }, - } --[[@as snacks.win.Config]], config.snacks_win_opts or {}), + win = win_opts, } --[[@as snacks.terminal.Opts]] end +---Create a terminal buffer without keeping snacks' window +---Preserves existing window dimensions when window_manager already has a window +---@param cmd_string string Command to run +---@param env_table table Environment variables +---@param config table Terminal configuration +---@param session_id string Session ID +---@return table|nil term_instance The snacks terminal instance +local function create_terminal_buffer(cmd_string, env_table, config, session_id) + local logger = require("claudecode.logger") + local window_manager = require("claudecode.terminal.window_manager") + + -- Save existing window dimensions BEFORE snacks creates its window + -- Snacks.terminal.open() will create a split that may affect our window size + local saved_winid = window_manager.get_window() + local saved_width, saved_height + if saved_winid and vim.api.nvim_win_is_valid(saved_winid) then + saved_width = vim.api.nvim_win_get_width(saved_winid) + saved_height = vim.api.nvim_win_get_height(saved_winid) + logger.debug("terminal", string.format("Saved window dimensions: %dx%d", saved_width, saved_height)) + end + + local opts = build_opts(config, env_table, session_id) + local term_instance = Snacks.terminal.open(cmd_string, opts) + + if term_instance and term_instance:buf_valid() then + -- Immediately close snacks' window (buffer stays alive) + -- We'll display the buffer in window_manager's window instead + if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then + pcall(vim.api.nvim_win_close, term_instance.win, false) + term_instance.win = nil + logger.debug("terminal", "Closed snacks window for session: " .. session_id) + end + + -- Restore the original window dimensions if they were saved + -- This fixes the issue where creating a new session resizes the window + if saved_winid and vim.api.nvim_win_is_valid(saved_winid) and saved_width then + vim.api.nvim_win_set_width(saved_winid, saved_width) + if saved_height then + vim.api.nvim_win_set_height(saved_winid, saved_height) + end + logger.debug("terminal", string.format("Restored window dimensions: %dx%d", saved_width, saved_height or 0)) + end + + -- Track PID for cleanup on Neovim exit (with retry mechanism) + -- Snacks.terminal.open() may not set terminal_job_id immediately + if term_instance.buf then + track_pid_with_retry(term_instance.buf, 5, 50) + + -- Set up BufUnload autocmd to ensure job is stopped when buffer is deleted + -- This catches :bd!, Neovim exit, and any other buffer deletion path + -- that bypasses close_session() + vim.api.nvim_create_autocmd("BufUnload", { + buffer = term_instance.buf, + once = true, + callback = function() + local unload_ok, unload_job_id = pcall(vim.api.nvim_buf_get_var, term_instance.buf, "terminal_job_id") + if unload_ok and unload_job_id then + -- Get Unix PID from Neovim job ID + local pid_ok, pid = pcall(vim.fn.jobpid, unload_job_id) + if pid_ok and pid and pid > 0 then + -- Kill child processes first (shell wrappers like fish don't forward SIGTERM) + pcall(vim.fn.system, "pkill -TERM -P " .. pid .. " 2>/dev/null") + end + pcall(vim.fn.jobstop, unload_job_id) + end + end, + }) + end + + setup_terminal_events(term_instance, config, session_id) + return term_instance + end + + return nil +end + function M.setup() -- No specific setup needed for Snacks provider end ----Open a terminal using Snacks.nvim +---Open a terminal using Snacks.nvim (legacy interface) ---@param cmd_string string ---@param env_table table ---@param config ClaudeCodeTerminalConfig @@ -91,71 +408,66 @@ function M.open(cmd_string, env_table, config, focus) return end + local window_manager = require("claudecode.terminal.window_manager") focus = utils.normalize_focus(focus) if terminal and terminal:buf_valid() then - -- 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 + -- Terminal exists, display it via window manager + window_manager.display_buffer(terminal.buf, focus) return end - local opts = build_opts(config, env_table, focus) - local term_instance = Snacks.terminal.open(cmd_string, opts) + -- Ensure a session exists + local session_id = session_manager.ensure_session() + + -- Create terminal buffer + local term_instance = create_terminal_buffer(cmd_string, env_table, config, session_id) + if term_instance and term_instance:buf_valid() then - setup_terminal_events(term_instance, config) terminal = term_instance + terminals[session_id] = term_instance + + -- Display buffer via window manager + window_manager.display_buffer(term_instance.buf, focus) + + -- Set up smart ESC handling if enabled + local terminal_module = require("claudecode.terminal") + if config.esc_timeout and config.esc_timeout > 0 and term_instance.buf then + terminal_module.setup_terminal_keymaps(term_instance.buf, config) + end + + -- Prevent mouse scroll from escaping to terminal scrollback + if term_instance.buf then + vim.keymap.set("t", "", "", { buffer = term_instance.buf, silent = true }) + vim.keymap.set("t", "", "", { buffer = term_instance.buf, silent = true }) + end + + -- Update session info + session_manager.update_terminal_info(session_id, { + bufnr = term_instance.buf, + winid = window_manager.get_window(), + }) + + -- Register buffer-to-session mapping for cleanup on BufUnload (Fix 1) + require("claudecode.terminal").register_buffer_session(term_instance.buf, session_id) + + -- Attach tabbar + local winid = window_manager.get_window() + if winid then + vim.schedule(function() + if vim.api.nvim_win_is_valid(winid) then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, term_instance.buf, term_instance) + end + end + end) + end else terminal = nil local logger = require("claudecode.logger") - local error_details = {} - if not term_instance then - table.insert(error_details, "Snacks.terminal.open() returned nil") - elseif not term_instance:buf_valid() then - table.insert(error_details, "terminal instance is invalid") - if term_instance.buf and not vim.api.nvim_buf_is_valid(term_instance.buf) then - table.insert(error_details, "buffer is invalid") - end - if term_instance.win and not vim.api.nvim_win_is_valid(term_instance.win) then - table.insert(error_details, "window is invalid") - end - end - - local context = string.format("cmd='%s', opts=%s", cmd_string, vim.inspect(opts)) - local error_msg = string.format( - "Failed to open Claude terminal using Snacks. Details: %s. Context: %s", - table.concat(error_details, ", "), - context - ) - vim.notify(error_msg, vim.log.levels.ERROR) - logger.debug("terminal", error_msg) + logger.error("terminal", "Failed to open Claude terminal using Snacks") + vim.notify("Failed to open Claude terminal using Snacks.", vim.log.levels.ERROR) end end @@ -164,9 +476,8 @@ function M.close() if not is_available() then return end - if terminal and terminal:buf_valid() then - terminal:close() - end + local window_manager = require("claudecode.terminal.window_manager") + window_manager.close_window() end ---Simple toggle: always show/hide terminal regardless of focus @@ -179,17 +490,17 @@ function M.simple_toggle(cmd_string, env_table, config) return end + local window_manager = require("claudecode.terminal.window_manager") local logger = require("claudecode.logger") - -- Check if terminal exists and is visible - if terminal and terminal:buf_valid() and terminal:win_valid() then + if window_manager.is_visible() then -- Terminal is visible, hide it - logger.debug("terminal", "Simple toggle: hiding visible terminal") - terminal:toggle() - elseif terminal and terminal:buf_valid() and not terminal:win_valid() then - -- Terminal exists but not visible, show it + logger.debug("terminal", "Simple toggle: hiding terminal") + window_manager.close_window() + elseif terminal and terminal:buf_valid() then + -- Terminal buffer exists but not visible, show it logger.debug("terminal", "Simple toggle: showing hidden terminal") - terminal:toggle() + window_manager.display_buffer(terminal.buf, true) else -- No terminal exists, create new one logger.debug("terminal", "Simple toggle: creating new terminal") @@ -207,37 +518,33 @@ function M.focus_toggle(cmd_string, env_table, config) return end + local window_manager = require("claudecode.terminal.window_manager") local logger = require("claudecode.logger") - -- Terminal exists, is valid, but not visible - if terminal and terminal:buf_valid() and not terminal:win_valid() then - logger.debug("terminal", "Focus toggle: showing hidden terminal") - terminal:toggle() - -- Terminal exists, is valid, and is visible - elseif terminal and terminal:buf_valid() and terminal:win_valid() then - local claude_term_neovim_win_id = terminal.win - local current_neovim_win_id = vim.api.nvim_get_current_win() - - -- you're IN it - if claude_term_neovim_win_id == current_neovim_win_id then + if not window_manager.is_visible() then + -- Terminal not visible + if terminal and terminal:buf_valid() then + logger.debug("terminal", "Focus toggle: showing hidden terminal") + window_manager.display_buffer(terminal.buf, true) + else + logger.debug("terminal", "Focus toggle: creating new terminal") + M.open(cmd_string, env_table, config) + end + else + -- Terminal is visible + local winid = window_manager.get_window() + local current_win = vim.api.nvim_get_current_win() + + if winid == current_win then + -- We're focused on terminal, hide it logger.debug("terminal", "Focus toggle: hiding terminal (currently focused)") - terminal:toggle() - -- you're NOT in it + window_manager.close_window() else + -- Terminal visible but not focused, focus it logger.debug("terminal", "Focus toggle: focusing terminal") - vim.api.nvim_set_current_win(claude_term_neovim_win_id) - if terminal.buf and vim.api.nvim_buf_is_valid(terminal.buf) then - if vim.api.nvim_buf_get_option(terminal.buf, "buftype") == "terminal" then - vim.api.nvim_win_call(claude_term_neovim_win_id, function() - vim.cmd("startinsert") - end) - end - end + vim.api.nvim_set_current_win(winid) + vim.cmd("startinsert") end - -- No terminal exists - else - logger.debug("terminal", "Focus toggle: creating new terminal") - M.open(cmd_string, env_table, config) end end @@ -272,5 +579,367 @@ function M._get_terminal_for_test() return terminal end +-- ============================================================================ +-- Multi-session support functions +-- ============================================================================ + +---Open a terminal for a specific session +---@param session_id string The session ID +---@param cmd_string string The command to run +---@param env_table table Environment variables +---@param config ClaudeCodeTerminalConfig Terminal configuration +---@param focus boolean? Whether to focus the terminal +function M.open_session(session_id, 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 + + local logger = require("claudecode.logger") + local window_manager = require("claudecode.terminal.window_manager") + focus = utils.normalize_focus(focus) + + -- Check if this session already has a terminal + local existing_term = terminals[session_id] + if existing_term and existing_term:buf_valid() then + -- Terminal exists, display it via window manager + window_manager.display_buffer(existing_term.buf, focus) + + -- Update tabbar + local winid = window_manager.get_window() + if winid then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, existing_term.buf, existing_term) + end + end + + logger.debug("terminal", "Displayed existing terminal for session: " .. session_id) + return + end + + -- Create new terminal buffer for this session + local term_instance = create_terminal_buffer(cmd_string, env_table, config, session_id) + + if term_instance and term_instance:buf_valid() then + terminals[session_id] = term_instance + terminal = term_instance -- Also set as legacy terminal + + -- Display buffer via window manager + window_manager.display_buffer(term_instance.buf, focus) + + -- Update session manager with terminal info + local terminal_module = require("claudecode.terminal") + terminal_module.update_session_terminal_info(session_id, { + bufnr = term_instance.buf, + winid = window_manager.get_window(), + }) + + -- Register buffer-to-session mapping for cleanup on BufUnload (Fix 1) + terminal_module.register_buffer_session(term_instance.buf, session_id) + + -- Set up smart ESC handling if enabled + if config.esc_timeout and config.esc_timeout > 0 and term_instance.buf then + terminal_module.setup_terminal_keymaps(term_instance.buf, config) + end + + -- Prevent mouse scroll from escaping to terminal scrollback + if term_instance.buf then + vim.keymap.set("t", "", "", { buffer = term_instance.buf, silent = true }) + vim.keymap.set("t", "", "", { buffer = term_instance.buf, silent = true }) + end + + -- Setup OSC title handler + if term_instance.buf then + osc_handler.setup_buffer_handler(term_instance.buf, function(title) + if title and title ~= "" then + session_manager.update_session_name(session_id, title) + end + end) + end + + -- Attach tabbar + local winid = window_manager.get_window() + if winid then + vim.schedule(function() + if vim.api.nvim_win_is_valid(winid) then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, term_instance.buf, term_instance) + end + end + end) + end + + logger.debug("terminal", "Opened terminal for session: " .. session_id) + else + logger.error("terminal", "Failed to open terminal for session: " .. session_id) + end +end + +---Close a terminal for a specific session +---@param session_id string The session ID +function M.close_session(session_id) + if not is_available() then + return + end + + local term_instance = terminals[session_id] + if term_instance and term_instance:buf_valid() then + -- Mark as intentional close to suppress error message + closing_sessions[session_id] = true + + -- Stop the job first to ensure the process is terminated + if term_instance.buf then + local ok, job_id = pcall(vim.api.nvim_buf_get_var, term_instance.buf, "terminal_job_id") + if ok and job_id then + -- Get Unix PID from Neovim job ID + local pid_ok, pid = pcall(vim.fn.jobpid, job_id) + if pid_ok and pid and pid > 0 then + -- Kill child processes first (shell wrappers like fish don't forward SIGTERM) + pcall(vim.fn.system, "pkill -TERM -P " .. pid .. " 2>/dev/null") + end + -- Then kill the shell process + pcall(vim.fn.jobstop, job_id) + end + end + + -- Cleanup OSC handler + if term_instance.buf then + osc_handler.cleanup_buffer_handler(term_instance.buf) + end + + -- Delete the buffer + if term_instance.buf and vim.api.nvim_buf_is_valid(term_instance.buf) then + vim.api.nvim_buf_delete(term_instance.buf, { force = true }) + end + + terminals[session_id] = nil + + -- If this was the legacy terminal, clear it too + if terminal == term_instance then + terminal = nil + end + end +end + +---Close a session's terminal but keep window open and switch to another session +---@param old_session_id string The session ID to close +---@param new_session_id string The session ID to switch to +---@param effective_config ClaudeCodeTerminalConfig Terminal configuration +function M.close_session_keep_window(old_session_id, new_session_id, effective_config) + if not is_available() then + return + end + + local logger = require("claudecode.logger") + local window_manager = require("claudecode.terminal.window_manager") + + local old_term = terminals[old_session_id] + local new_term = terminals[new_session_id] + + -- Try to find the new session's terminal if not in terminals table + if not new_term or not new_term:buf_valid() then + local session_data = session_manager.get_session(new_session_id) + if session_data and session_data.terminal_bufnr then + if terminal and terminal:buf_valid() and terminal.buf == session_data.terminal_bufnr then + new_term = terminal + terminals[new_session_id] = new_term + logger.debug("terminal", "Recovered legacy terminal for new session: " .. new_session_id) + end + end + end + + -- Try to find old_term from legacy terminal + if not old_term then + local old_session_data = session_manager.get_session(old_session_id) + if old_session_data and old_session_data.terminal_bufnr then + if terminal and terminal:buf_valid() and terminal.buf == old_session_data.terminal_bufnr then + old_term = terminal + logger.debug("terminal", "Using legacy terminal as old_term for: " .. old_session_id) + end + end + if not old_term then + logger.debug("terminal", "No terminal found for old session: " .. old_session_id) + return + end + end + + -- Mark as intentional close + closing_sessions[old_session_id] = true + + -- If new terminal exists, display it via window manager + if new_term and new_term:buf_valid() then + window_manager.display_buffer(new_term.buf, true) + + -- Update legacy terminal reference + terminal = new_term + + -- Update tabbar + local winid = window_manager.get_window() + if winid then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, new_term.buf, new_term) + end + end + else + -- No valid new_term found - close the window + logger.warn("terminal", "No valid terminal found for new session: " .. new_session_id) + window_manager.close_window() + end + + -- Stop the old job first to ensure the process is terminated + -- Kill child processes first (shell wrappers like fish don't forward SIGTERM) + if old_term and old_term:buf_valid() and old_term.buf then + local ok, job_id = pcall(vim.api.nvim_buf_get_var, old_term.buf, "terminal_job_id") + if ok and job_id then + -- Get Unix PID from Neovim job ID + local pid_ok, pid = pcall(vim.fn.jobpid, job_id) + if pid_ok and pid and pid > 0 then + -- Kill child processes first (e.g., claude spawned by fish) + pcall(vim.fn.system, "pkill -TERM -P " .. pid .. " 2>/dev/null") + end + -- Then kill the shell process + pcall(vim.fn.jobstop, job_id) + end + end + + -- Clean up old terminal's buffer + if old_term and old_term:buf_valid() then + if old_term.buf then + osc_handler.cleanup_buffer_handler(old_term.buf) + end + if old_term.buf and vim.api.nvim_buf_is_valid(old_term.buf) then + vim.api.nvim_buf_delete(old_term.buf, { force = true }) + end + end + + terminals[old_session_id] = nil + -- NOTE: Don't clear closing_sessions here - let TermClose handler do it + -- If we clear it before TermClose fires, the handler thinks it's a crash + -- and incorrectly closes the window + + logger.debug("terminal", "Closed session " .. old_session_id .. " and switched to " .. new_session_id) +end + +---Focus a terminal for a specific session +---@param session_id string The session ID +---@param config ClaudeCodeTerminalConfig|nil Terminal configuration for showing hidden terminal +function M.focus_session(session_id, config) + if not is_available() then + return + end + + local logger = require("claudecode.logger") + local window_manager = require("claudecode.terminal.window_manager") + + local term_instance = terminals[session_id] + + -- If not found in terminals table, try fallback to legacy terminal + if not term_instance or not term_instance:buf_valid() then + local session_mod = require("claudecode.session") + local session = session_mod.get_session(session_id) + if + session + and session.terminal_bufnr + and terminal + and terminal:buf_valid() + and terminal.buf == session.terminal_bufnr + then + logger.debug("terminal", "Registering legacy terminal for session: " .. session_id) + M.register_terminal_for_session(session_id, terminal.buf) + term_instance = terminals[session_id] + end + + if not term_instance or not term_instance:buf_valid() then + logger.debug("terminal", "Cannot focus invalid session: " .. session_id) + return + end + end + + -- Display buffer via window manager + window_manager.display_buffer(term_instance.buf, true) + + -- Update legacy terminal reference + terminal = term_instance + + -- Update tabbar + local winid = window_manager.get_window() + if winid then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(winid, term_instance.buf, term_instance) + end + end + + logger.debug("terminal", "Focused session: " .. session_id) +end + +---Get the buffer number for a session's terminal +---@param session_id string The session ID +---@return number|nil bufnr The buffer number or nil +function M.get_session_bufnr(session_id) + local term_instance = terminals[session_id] + if term_instance and term_instance:buf_valid() and term_instance.buf then + return term_instance.buf + end + return nil +end + +---Get all session IDs with active terminals +---@return string[] session_ids Array of session IDs +function M.get_active_session_ids() + local ids = {} + for session_id, term_instance in pairs(terminals) do + if term_instance and term_instance:buf_valid() then + table.insert(ids, session_id) + end + end + return ids +end + +---Register an existing terminal (from legacy path) with a session ID +---@param session_id string The session ID +---@param term_bufnr number|nil The buffer number (uses legacy terminal's bufnr if nil) +function M.register_terminal_for_session(session_id, term_bufnr) + local logger = require("claudecode.logger") + + if not term_bufnr and terminal and terminal:buf_valid() then + term_bufnr = terminal.buf + end + + if not term_bufnr then + logger.debug("terminal", "Cannot register nil terminal for session: " .. session_id) + return + end + + -- Check if already registered to another session + for sid, term_instance in pairs(terminals) do + if term_instance and term_instance:buf_valid() and term_instance.buf == term_bufnr and sid ~= session_id then + logger.debug( + "terminal", + "Terminal already registered to session " .. sid .. ", not registering to " .. session_id + ) + return + end + end + + -- Check if this session already has a different terminal + local existing_term = terminals[session_id] + if existing_term and existing_term:buf_valid() and existing_term.buf ~= term_bufnr then + logger.debug("terminal", "Session " .. session_id .. " already has a different terminal") + return + end + + -- Register the legacy terminal with the session + if terminal and terminal:buf_valid() and terminal.buf == term_bufnr then + terminals[session_id] = terminal + logger.debug("terminal", "Registered terminal (bufnr=" .. term_bufnr .. ") for session: " .. session_id) + else + logger.debug("terminal", "Cannot register: terminal bufnr mismatch for session: " .. session_id) + end +end + ---@type ClaudeCodeTerminalProvider return M diff --git a/lua/claudecode/terminal/tabbar.lua b/lua/claudecode/terminal/tabbar.lua new file mode 100644 index 00000000..ec90418a --- /dev/null +++ b/lua/claudecode/terminal/tabbar.lua @@ -0,0 +1,709 @@ +--- Tab bar module for Claude Code terminal session switching. +--- Creates a separate floating window for session tabs. +--- @module 'claudecode.terminal.tabbar' + +local M = {} + +-- Lazy load session_manager to avoid circular dependency +local session_manager +local function get_session_manager() + if not session_manager then + session_manager = require("claudecode.session") + end + return session_manager +end + +---@class TabBarState +---@field tabbar_win number|nil The tab bar floating window +---@field tabbar_buf number|nil The tab bar buffer +---@field terminal_win number|nil The terminal window we're attached to +---@field augroup number|nil The autocmd group +---@field config table The tabs configuration + +---@type TabBarState +local state = { + tabbar_win = nil, + tabbar_buf = nil, + terminal_win = nil, + augroup = nil, + config = nil, +} + +-- ============================================================================ +-- Highlight Groups +-- ============================================================================ + +local function setup_highlights() + if not vim.api.nvim_set_hl then + return + end + + local hl = vim.api.nvim_set_hl + hl(0, "ClaudeCodeTabBar", { link = "StatusLine", default = true }) + hl(0, "ClaudeCodeTabActive", { link = "TabLineSel", default = true }) + hl(0, "ClaudeCodeTabInactive", { link = "TabLine", default = true }) + hl(0, "ClaudeCodeTabNew", { link = "Special", default = true }) + hl(0, "ClaudeCodeTabClose", { link = "Error", default = true }) +end + +-- ============================================================================ +-- Tab Bar Content +-- ============================================================================ + +-- Track click regions for mouse support +local click_regions = {} -- Array of {start_col, end_col, action, session_id} + +---Handle mouse click at given column +---@param col number Column position (1-indexed) +local function handle_click(col) + for _, region in ipairs(click_regions) do + if col >= region.start_col and col <= region.end_col then + if region.action == "switch" and region.session_id then + vim.schedule(function() + require("claudecode.terminal").switch_to_session(region.session_id) + end) + elseif region.action == "close" and region.session_id then + vim.schedule(function() + require("claudecode.terminal").close_session(region.session_id) + end) + elseif region.action == "new" then + vim.schedule(function() + require("claudecode.terminal").open_new_session() + end) + end + return true + end + end + return false +end + +---Build the tab bar content line +---@return string content The tab bar content +---@return table highlights Array of {col_start, col_end, hl_group} +local function build_content() + local sessions = get_session_manager().list_sessions() + local active_id = get_session_manager().get_active_session_id() + + -- Reset click regions + click_regions = {} + + if #sessions == 0 then + return " Claude Code ", {} + end + + local parts = {} + local highlights = {} + local col = 1 -- 1-indexed column position + + for i, session in ipairs(sessions) do + local is_active = session.id == active_id + local name = session.name or ("Session " .. i) + if #name > 12 then + name = name:sub(1, 9) .. "..." + end + + local label = string.format(" %d:%s ", i, name) + local hl_group = is_active and "ClaudeCodeTabActive" or "ClaudeCodeTabInactive" + + -- Track click region for this tab + table.insert(click_regions, { + start_col = col, + end_col = col + #label - 1, + action = "switch", + session_id = session.id, + }) + + table.insert(highlights, { col - 1, col - 1 + #label, hl_group }) + table.insert(parts, label) + col = col + #label + + -- Add close button if enabled + if state.config and state.config.show_close_button then + local close_btn = "✕ " + table.insert(click_regions, { + start_col = col, + end_col = col + #close_btn - 1, + action = "close", + session_id = session.id, + }) + -- Use same highlight as tab for consistent background + table.insert(highlights, { col - 1, col - 1 + #close_btn, hl_group }) + table.insert(parts, close_btn) + col = col + #close_btn + end + + if i < #sessions then + table.insert(parts, "|") + col = col + 1 + end + end + + -- Add new session button + if state.config and state.config.show_new_button then + local new_btn = " + " + table.insert(click_regions, { + start_col = col, + end_col = col + #new_btn - 1, + action = "new", + }) + table.insert(parts, new_btn) + table.insert(highlights, { col - 1, col - 1 + #new_btn, "ClaudeCodeTabNew" }) + end + + return table.concat(parts), highlights +end + +-- ============================================================================ +-- Tab Bar Window Management +-- ============================================================================ + +---Check if a mouse click is in the tabbar window and handle it +---@return boolean handled True if click was handled +local function check_and_handle_tabbar_click() + if not state.tabbar_win or not vim.api.nvim_win_is_valid(state.tabbar_win) then + return false + end + + local mouse = vim.fn.getmousepos() + if not mouse or mouse.winid ~= state.tabbar_win then + return false + end + + local col = mouse.wincol or mouse.column or 1 + return handle_click(col) +end + +---Create or update the tab bar buffer +local function ensure_buffer() + if state.tabbar_buf and vim.api.nvim_buf_is_valid(state.tabbar_buf) then + return state.tabbar_buf + end + + state.tabbar_buf = vim.api.nvim_create_buf(false, true) + vim.bo[state.tabbar_buf].buftype = "nofile" + vim.bo[state.tabbar_buf].bufhidden = "hide" + vim.bo[state.tabbar_buf].swapfile = false + vim.bo[state.tabbar_buf].modifiable = true + + return state.tabbar_buf +end + +---Handle middle click to close session +---@param col number Column position (1-indexed) +---@return boolean handled True if click was handled +local function handle_middle_click(col) + for _, region in ipairs(click_regions) do + if col >= region.start_col and col <= region.end_col then + if region.action == "switch" and region.session_id then + vim.schedule(function() + require("claudecode.terminal").close_session(region.session_id) + end) + return true + end + end + end + return false +end + +---Handle scroll wheel to cycle sessions +---@param direction string "up" or "down" +local function handle_scroll(direction) + local sessions = get_session_manager().list_sessions() + local active_id = get_session_manager().get_active_session_id() + + if #sessions <= 1 then + return + end + + for i, session in ipairs(sessions) do + if session.id == active_id then + local next_idx + if direction == "up" then + next_idx = ((i - 2) % #sessions) + 1 -- Previous + else + next_idx = (i % #sessions) + 1 -- Next + end + vim.schedule(function() + require("claudecode.terminal").switch_to_session(sessions[next_idx].id) + end) + return + end + end +end + +---Check if scroll is in tabbar and handle it +---@param direction string "up" or "down" +---@return boolean handled True if scroll was handled +local function check_and_handle_tabbar_scroll(direction) + if not state.tabbar_win or not vim.api.nvim_win_is_valid(state.tabbar_win) then + return false + end + + local mouse = vim.fn.getmousepos() + if not mouse or mouse.winid ~= state.tabbar_win then + return false + end + + handle_scroll(direction) + return true +end + +---Check if middle click is in tabbar and handle it +---@return boolean handled True if click was handled +local function check_and_handle_tabbar_middle_click() + if not state.tabbar_win or not vim.api.nvim_win_is_valid(state.tabbar_win) then + return false + end + + local mouse = vim.fn.getmousepos() + if not mouse or mouse.winid ~= state.tabbar_win then + return false + end + + local col = mouse.wincol or mouse.column or 1 + return handle_middle_click(col) +end + +---Setup global mouse click handler +local mouse_handler_set = false +local function setup_mouse_handler() + if mouse_handler_set then + return + end + mouse_handler_set = true + + -- Cache termcodes for mouse events + local left_mouse = vim.api.nvim_replace_termcodes("", true, false, true) + local left_release = vim.api.nvim_replace_termcodes("", true, false, true) + local middle_mouse = vim.api.nvim_replace_termcodes("", true, false, true) + local middle_release = vim.api.nvim_replace_termcodes("", true, false, true) + local scroll_up = vim.api.nvim_replace_termcodes("", true, false, true) + local scroll_down = vim.api.nvim_replace_termcodes("", true, false, true) + + -- Global mouse handler that checks if click is in tabbar + vim.on_key(function(key) + if key == left_mouse or key == left_release then + vim.schedule(function() + check_and_handle_tabbar_click() + end) + elseif key == middle_mouse or key == middle_release then + vim.schedule(function() + check_and_handle_tabbar_middle_click() + end) + elseif key == scroll_up then + vim.schedule(function() + check_and_handle_tabbar_scroll("up") + end) + elseif key == scroll_down then + vim.schedule(function() + check_and_handle_tabbar_scroll("down") + end) + end + end) +end + +---Calculate position for tab bar window (above terminal) +---@param term_win number Terminal window ID +---@return table|nil config Window config or nil if invalid +local function calc_window_config(term_win) + if not term_win or not vim.api.nvim_win_is_valid(term_win) then + return nil + end + + local term_config = vim.api.nvim_win_get_config(term_win) + local term_pos = vim.api.nvim_win_get_position(term_win) + local term_width = vim.api.nvim_win_get_width(term_win) + + -- For floating windows + if term_config.relative and term_config.relative ~= "" then + return { + relative = "editor", + row = term_pos[1], + col = term_pos[2], + width = term_width, + height = 1, + style = "minimal", + border = "none", + zindex = (term_config.zindex or 50) + 1, + focusable = true, -- Allow clicks + } + end + + -- For split windows, use winbar instead (handled separately) + return nil +end + +---Show the tab bar window +function M.show() + if not state.config or not state.config.enabled then + return + end + + if not state.terminal_win or not vim.api.nvim_win_is_valid(state.terminal_win) then + return + end + + local win_config = calc_window_config(state.terminal_win) + + if not win_config then + -- Fallback to winbar for split windows + M.render_winbar() + return + end + + ensure_buffer() + + -- Create or update window + if state.tabbar_win and vim.api.nvim_win_is_valid(state.tabbar_win) then + vim.api.nvim_win_set_config(state.tabbar_win, win_config) + else + state.tabbar_win = vim.api.nvim_open_win(state.tabbar_buf, false, win_config) + vim.api.nvim_win_set_option(state.tabbar_win, "winhl", "Normal:ClaudeCodeTabBar") + end + + M.render() +end + +---Hide the tab bar window +function M.hide() + if state.tabbar_win and vim.api.nvim_win_is_valid(state.tabbar_win) then + vim.api.nvim_win_close(state.tabbar_win, true) + end + state.tabbar_win = nil + + -- Also clear winbar + if state.terminal_win and vim.api.nvim_win_is_valid(state.terminal_win) then + pcall(function() + vim.wo[state.terminal_win].winbar = nil + end) + end +end + +---Render tab bar content +function M.render() + if not state.config or not state.config.enabled then + return + end + + local content, highlights = build_content() + + -- Update floating window if exists + if state.tabbar_win and vim.api.nvim_win_is_valid(state.tabbar_win) then + if state.tabbar_buf and vim.api.nvim_buf_is_valid(state.tabbar_buf) then + vim.api.nvim_buf_set_lines(state.tabbar_buf, 0, -1, false, { content }) + + -- Apply highlights + local ns = vim.api.nvim_create_namespace("claudecode_tabbar") + vim.api.nvim_buf_clear_namespace(state.tabbar_buf, ns, 0, -1) + for _, hl in ipairs(highlights) do + pcall(vim.api.nvim_buf_add_highlight, state.tabbar_buf, ns, hl[3], 0, hl[1], hl[2]) + end + end + + -- Update window position in case terminal moved + local win_config = calc_window_config(state.terminal_win) + if win_config then + pcall(vim.api.nvim_win_set_config, state.tabbar_win, win_config) + end + else + -- Try winbar fallback + M.render_winbar() + end +end + +-- Store session IDs for winbar click handlers (indexed by position) +local winbar_session_ids = {} + +---Global click handler for winbar session tabs +---@param session_idx number The 1-indexed session position +---@param clicks number Number of clicks +---@param button string Mouse button ("l", "m", "r") +---@param mods string Modifiers +function _G.ClaudeCodeTabClick(session_idx, clicks, button, mods) + local session_id = winbar_session_ids[session_idx] + if not session_id then + return + end + + vim.schedule(function() + if button == "l" then + -- Left click: switch to session + require("claudecode.terminal").switch_to_session(session_id) + elseif button == "m" then + -- Middle click: close session + require("claudecode.terminal").close_session(session_id) + end + end) +end + +---Global click handler for winbar close button +---@param session_idx number The 1-indexed session position +---@param clicks number Number of clicks +---@param button string Mouse button ("l", "m", "r") +---@param mods string Modifiers +function _G.ClaudeCodeCloseTabClick(session_idx, clicks, button, mods) + local session_id = winbar_session_ids[session_idx] + if not session_id then + return + end + + if button == "l" then + vim.schedule(function() + require("claudecode.terminal").close_session(session_id) + end) + end +end + +---Global click handler for winbar new session button +---@param _ number Unused +---@param clicks number Number of clicks +---@param button string Mouse button +---@param mods string Modifiers +function _G.ClaudeCodeNewTabClick(_, clicks, button, mods) + if button == "l" then + vim.schedule(function() + require("claudecode.terminal").open_new_session() + end) + end +end + +---Render to winbar (for split windows) +function M.render_winbar() + if not state.terminal_win or not vim.api.nvim_win_is_valid(state.terminal_win) then + return + end + + local sessions = get_session_manager().list_sessions() + local active_id = get_session_manager().get_active_session_id() + + if #sessions == 0 then + return + end + + -- Reset session ID mapping + winbar_session_ids = {} + + local parts = {} + for i, session in ipairs(sessions) do + local is_active = session.id == active_id + local name = session.name or ("Session " .. i) + if #name > 12 then + name = name:sub(1, 9) .. "..." + end + + -- Store session ID for click handler + winbar_session_ids[i] = session.id + + local hl = is_active and "%#ClaudeCodeTabActive#" or "%#ClaudeCodeTabInactive#" + -- Use %@FuncName@ syntax for clickable regions + -- %@FuncName@ calls FuncName(nr, clicks, button, mods) + local click_start = string.format("%%%d@v:lua.ClaudeCodeTabClick@", i) + local click_end = "%X" + + -- Build tab content with optional close button + local tab_content = hl .. " " .. i .. ":" .. name .. " " + + if state.config and state.config.show_close_button then + local close_click = string.format("%%%d@v:lua.ClaudeCodeCloseTabClick@", i) + -- Keep same highlight as tab for consistent background + tab_content = tab_content .. click_end .. close_click .. hl .. "✕%X " + end + + table.insert(parts, click_start .. tab_content .. click_end) + end + + if state.config and state.config.show_new_button then + local click_start = "%0@v:lua.ClaudeCodeNewTabClick@" + local click_end = "%X" + table.insert(parts, click_start .. "%#ClaudeCodeTabNew# + " .. click_end) + end + + local winbar = table.concat(parts, "%#StatusLine#|") .. "%#Normal#" + pcall(function() + vim.wo[state.terminal_win].winbar = winbar + end) +end + +-- ============================================================================ +-- Keyboard Navigation +-- ============================================================================ + +---Setup keymaps for session switching +---@param bufnr number Buffer number +function M.setup_keymaps(bufnr) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local keymaps = state.config and state.config.keymaps or {} + + if keymaps.next_tab then + vim.keymap.set({ "n", "t" }, keymaps.next_tab, function() + local sessions = get_session_manager().list_sessions() + local active_id = get_session_manager().get_active_session_id() + if #sessions <= 1 then + return + end + for i, session in ipairs(sessions) do + if session.id == active_id then + local next_idx = (i % #sessions) + 1 + require("claudecode.terminal").switch_to_session(sessions[next_idx].id) + return + end + end + end, { buffer = bufnr, desc = "Next Claude session" }) + end + + if keymaps.prev_tab then + vim.keymap.set({ "n", "t" }, keymaps.prev_tab, function() + local sessions = get_session_manager().list_sessions() + local active_id = get_session_manager().get_active_session_id() + if #sessions <= 1 then + return + end + for i, session in ipairs(sessions) do + if session.id == active_id then + local prev_idx = ((i - 2) % #sessions) + 1 + require("claudecode.terminal").switch_to_session(sessions[prev_idx].id) + return + end + end + end, { buffer = bufnr, desc = "Previous Claude session" }) + end + + if keymaps.new_tab then + vim.keymap.set({ "n", "t" }, keymaps.new_tab, function() + require("claudecode.terminal").open_new_session() + end, { buffer = bufnr, desc = "New Claude session" }) + end + + if keymaps.close_tab then + vim.keymap.set({ "n", "t" }, keymaps.close_tab, function() + local active_id = get_session_manager().get_active_session_id() + if active_id then + require("claudecode.terminal").close_session(active_id) + end + end, { buffer = bufnr, desc = "Close Claude session" }) + end +end + +-- ============================================================================ +-- Event Handling +-- ============================================================================ + +local function setup_autocmds() + if state.augroup then + pcall(vim.api.nvim_del_augroup_by_id, state.augroup) + end + + state.augroup = vim.api.nvim_create_augroup("ClaudeCodeTabBar", { clear = true }) + + -- Update on window resize/move + vim.api.nvim_create_autocmd({ "WinResized", "WinScrolled" }, { + group = state.augroup, + callback = function() + if state.tabbar_win and vim.api.nvim_win_is_valid(state.tabbar_win) then + M.show() + end + end, + }) + + -- Update on session events + vim.api.nvim_create_autocmd("User", { + group = state.augroup, + pattern = { "ClaudeCodeSessionCreated", "ClaudeCodeSessionDestroyed", "ClaudeCodeSessionNameChanged" }, + callback = function() + M.render() + end, + }) + + -- Clean up when terminal closes + vim.api.nvim_create_autocmd("WinClosed", { + group = state.augroup, + callback = function(args) + local win = tonumber(args.match) + if win == state.terminal_win then + M.hide() + state.terminal_win = nil + end + end, + }) +end + +-- ============================================================================ +-- Public API +-- ============================================================================ + +---Initialize the tab bar module +---@param config table Tabs configuration +function M.setup(config) + state.config = config + setup_highlights() + + if config and config.enabled then + setup_autocmds() + setup_mouse_handler() + end +end + +---Attach tab bar to a terminal window +---@param terminal_win number Terminal window ID +---@param terminal_bufnr number|nil Terminal buffer (for keymaps) +---@param _ any Unused (kept for API compatibility) +function M.attach(terminal_win, terminal_bufnr, _) + if not state.config or not state.config.enabled then + return + end + + state.terminal_win = terminal_win + + if terminal_bufnr then + M.setup_keymaps(terminal_bufnr) + end + + M.show() +end + +---Detach tab bar +function M.detach() + M.hide() + state.terminal_win = nil +end + +---Check if tab bar is visible +---@return boolean +function M.is_visible() + return (state.tabbar_win and vim.api.nvim_win_is_valid(state.tabbar_win)) + or ( + state.terminal_win + and vim.api.nvim_win_is_valid(state.terminal_win) + and vim.wo[state.terminal_win].winbar ~= "" + ) +end + +---Get tab bar window ID +---@return number|nil +function M.get_winid() + return state.tabbar_win +end + +---Cleanup +function M.cleanup() + M.hide() + + if state.augroup then + pcall(vim.api.nvim_del_augroup_by_id, state.augroup) + state.augroup = nil + end + + if state.tabbar_buf and vim.api.nvim_buf_is_valid(state.tabbar_buf) then + pcall(vim.api.nvim_buf_delete, state.tabbar_buf, { force = true }) + end + + state.tabbar_buf = nil + state.tabbar_win = nil + state.terminal_win = nil + state.config = nil +end + +return M diff --git a/lua/claudecode/terminal/window_manager.lua b/lua/claudecode/terminal/window_manager.lua new file mode 100644 index 00000000..8971031d --- /dev/null +++ b/lua/claudecode/terminal/window_manager.lua @@ -0,0 +1,216 @@ +---Window manager for Claude Code terminal. +---Singleton module that owns THE terminal window. Providers create buffers, +---window_manager displays them in the single managed window. +---@module 'claudecode.terminal.window_manager' + +local M = {} + +local logger = require("claudecode.logger") + +---@class WindowManagerState +---@field winid number|nil The single terminal window (nil if closed) +---@field current_bufnr number|nil Buffer currently displayed +---@field config table|nil Window configuration (position, width, etc.) + +---@type WindowManagerState +local state = { + winid = nil, + current_bufnr = nil, + config = nil, +} + +---Find any existing terminal window in current tabpage +---@return number|nil winid +local function find_terminal_window() + local current_tab = vim.api.nvim_get_current_tabpage() + local wins = vim.api.nvim_tabpage_list_wins(current_tab) + + for _, win in ipairs(wins) do + if vim.api.nvim_win_is_valid(win) then + local buf = vim.api.nvim_win_get_buf(win) + local buftype = vim.bo[buf].buftype + if buftype == "terminal" then + return win + end + end + end + + return nil +end + +---Create a split window for the terminal +---@param config table Window configuration +---@return number|nil winid +local function create_split_window(config) + local split_side = config.split_side or "right" + local split_width_percentage = config.split_width_percentage or 0.4 + + -- Calculate dimensions + local total_width = vim.o.columns + local width = math.floor(total_width * split_width_percentage) + + -- Save current window to restore later if needed + local current_win = vim.api.nvim_get_current_win() + + -- Create the split + if split_side == "left" then + vim.cmd("topleft vertical new") + else + vim.cmd("botright vertical new") + end + + local winid = vim.api.nvim_get_current_win() + + -- Set window width + vim.api.nvim_win_set_width(winid, width) + + -- Set window options for terminal display + vim.wo[winid].number = false + vim.wo[winid].relativenumber = false + vim.wo[winid].signcolumn = "no" + vim.wo[winid].winfixwidth = false -- Allow user resizing + + logger.debug("window_manager", "Created split window: " .. winid .. " (width=" .. width .. ")") + + return winid +end + +---Initialize the window manager with configuration +---@param config table Configuration options +function M.setup(config) + state.config = config or {} + logger.debug("window_manager", "Window manager initialized") +end + +---Get or create the terminal window +---@return number|nil winid +function M.ensure_window() + -- Return existing window if valid + if state.winid and vim.api.nvim_win_is_valid(state.winid) then + return state.winid + end + + -- Search for any existing terminal window (recovery after external close) + state.winid = find_terminal_window() + if state.winid then + logger.debug("window_manager", "Recovered existing terminal window: " .. state.winid) + return state.winid + end + + -- Create new window (only happens once per visibility cycle) + if not state.config then + state.config = {} + end + state.winid = create_split_window(state.config) + return state.winid +end + +---Get the current terminal window (nil if none) +---@return number|nil winid +function M.get_window() + if state.winid and vim.api.nvim_win_is_valid(state.winid) then + return state.winid + end + return nil +end + +---Display a buffer in the terminal window +---Creates window if needed, switches buffer if window exists +---Calls jobresize() to notify terminal of dimensions +---@param bufnr number Buffer number to display +---@param focus boolean Whether to focus the window +---@return boolean success +function M.display_buffer(bufnr, focus) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + logger.warn("window_manager", "Cannot display invalid buffer: " .. tostring(bufnr)) + return false + end + + -- Ensure window exists + local winid = M.ensure_window() + if not winid then + logger.error("window_manager", "Failed to create terminal window") + return false + end + + -- Get current buffer in window (for scratch buffer cleanup) + local current_buf = vim.api.nvim_win_get_buf(winid) + local current_bufname = vim.api.nvim_buf_get_name(current_buf) + local is_scratch = current_bufname == "" and vim.bo[current_buf].buftype == "" + + -- Switch buffer in window (THE KEY OPERATION) + vim.api.nvim_win_set_buf(winid, bufnr) + state.current_bufnr = bufnr + + -- Clean up scratch buffer created by :new + if is_scratch and current_buf ~= bufnr and vim.api.nvim_buf_is_valid(current_buf) then + pcall(vim.api.nvim_buf_delete, current_buf, { force = true }) + end + + -- Notify terminal of dimensions (critical for cursor position) + local chan = vim.bo[bufnr].channel + if chan and chan > 0 then + local width = vim.api.nvim_win_get_width(winid) + local height = vim.api.nvim_win_get_height(winid) + pcall(vim.fn.jobresize, chan, width, height) + logger.debug("window_manager", string.format("Resized terminal channel %d to %dx%d", chan, width, height)) + end + + -- Focus if requested + if focus then + vim.api.nvim_set_current_win(winid) + vim.cmd("startinsert") + end + + logger.debug("window_manager", "Displayed buffer " .. bufnr .. " in window " .. winid) + return true +end + +---Close the terminal window +function M.close_window() + if state.winid and vim.api.nvim_win_is_valid(state.winid) then + -- Don't force close - let normal window close happen + pcall(vim.api.nvim_win_close, state.winid, false) + logger.debug("window_manager", "Closed terminal window: " .. state.winid) + end + state.winid = nil + state.current_bufnr = nil +end + +---Check if terminal window is visible +---@return boolean +function M.is_visible() + return state.winid ~= nil and vim.api.nvim_win_is_valid(state.winid) +end + +---Get window dimensions +---@return table|nil dimensions {width, height} or nil if no window +function M.get_dimensions() + if not state.winid or not vim.api.nvim_win_is_valid(state.winid) then + return nil + end + + return { + width = vim.api.nvim_win_get_width(state.winid), + height = vim.api.nvim_win_get_height(state.winid), + } +end + +---Get currently displayed buffer +---@return number|nil bufnr +function M.get_current_buffer() + return state.current_bufnr +end + +---Reset state (for testing or cleanup) +function M.reset() + M.close_window() + state = { + winid = nil, + current_bufnr = nil, + config = nil, + } + logger.debug("window_manager", "Reset window manager state") +end + +return M diff --git a/tests/integration/terminal_cleanup_integration_spec.lua b/tests/integration/terminal_cleanup_integration_spec.lua new file mode 100644 index 00000000..3a7538a2 --- /dev/null +++ b/tests/integration/terminal_cleanup_integration_spec.lua @@ -0,0 +1,253 @@ +---Integration tests for terminal cleanup functionality +---These tests spawn REAL processes and verify they are killed on cleanup +---Unlike unit tests, these don't use mocks and test actual behavior + +describe("terminal cleanup integration", function() + local terminal + + -- Helper to check if a process exists + local function process_exists(pid) + if not pid or pid <= 0 then + return false + end + -- kill -0 checks if process exists without killing it + local result = os.execute("kill -0 " .. pid .. " 2>/dev/null") + -- os.execute returns true/0 on success in Lua 5.1+ + return result == true or result == 0 + end + + -- Helper to spawn a test process and return its job_id + local function spawn_test_process() + -- Spawn a simple sleep process that will run for a while + local job_id = vim.fn.jobstart({ "sleep", "300" }, { + detach = false, + on_exit = function() end, + }) + return job_id + end + + -- Helper to get PID from job_id + local function get_pid(job_id) + local ok, pid = pcall(vim.fn.jobpid, job_id) + if ok and pid and pid > 0 then + return pid + end + return nil + end + + -- Helper to wait for process to die (with timeout) + local function wait_for_process_death(pid, timeout_ms) + timeout_ms = timeout_ms or 2000 + local start = vim.loop.now() + while vim.loop.now() - start < timeout_ms do + if not process_exists(pid) then + return true + end + -- Small delay + vim.loop.sleep(50) + end + return false + end + + before_each(function() + -- Clear any existing tracked PIDs + _G._claudecode_tracked_pids = {} + _G._claudecode_buffer_to_session = {} + + -- Reload terminal module fresh + package.loaded["claudecode.terminal"] = nil + terminal = require("claudecode.terminal") + end) + + after_each(function() + -- Ensure cleanup runs after each test + if terminal and terminal.cleanup_all then + pcall(terminal.cleanup_all) + end + end) + + describe("cleanup_all with real processes", function() + it("should kill a single tracked process", function() + -- Spawn a real process + local job_id = spawn_test_process() + assert.is_truthy(job_id, "Failed to spawn test process") + assert.is_true(job_id > 0, "Invalid job_id") + + -- Get its PID + local pid = get_pid(job_id) + assert.is_truthy(pid, "Failed to get PID from job_id") + + -- Verify process is running + assert.is_true(process_exists(pid), "Process should be running before cleanup") + + -- Track it + terminal.track_terminal_pid(job_id) + + -- Run cleanup + terminal.cleanup_all() + + -- Verify process is dead (give it some time) + local died = wait_for_process_death(pid, 2000) + assert.is_true(died, "Process " .. pid .. " should have been killed by cleanup_all") + end) + + it("should kill multiple tracked processes", function() + local jobs = {} + local pids = {} + + -- Spawn 3 processes + for i = 1, 3 do + local job_id = spawn_test_process() + assert.is_truthy(job_id, "Failed to spawn test process " .. i) + + local pid = get_pid(job_id) + assert.is_truthy(pid, "Failed to get PID for job " .. i) + + -- Verify running + assert.is_true(process_exists(pid), "Process " .. i .. " should be running") + + -- Track it + terminal.track_terminal_pid(job_id) + + table.insert(jobs, job_id) + table.insert(pids, pid) + end + + -- Run cleanup + terminal.cleanup_all() + + -- Verify all processes are dead + for i, pid in ipairs(pids) do + local died = wait_for_process_death(pid, 2000) + assert.is_true(died, "Process " .. i .. " (PID " .. pid .. ") should have been killed") + end + end) + + it("should kill child processes of tracked shells", function() + -- Spawn a shell that itself spawns a child process + -- This tests the pkill -P behavior + local job_id = vim.fn.jobstart({ "sh", "-c", "sleep 300 & sleep 300" }, { + detach = false, + on_exit = function() end, + }) + assert.is_truthy(job_id, "Failed to spawn shell") + + local shell_pid = get_pid(job_id) + assert.is_truthy(shell_pid, "Failed to get shell PID") + + -- Give the shell time to spawn its children + vim.loop.sleep(200) + + -- Find child PIDs using pgrep + local handle = io.popen("pgrep -P " .. shell_pid .. " 2>/dev/null") + local child_pids_str = handle:read("*a") + handle:close() + + local child_pids = {} + for pid_str in child_pids_str:gmatch("%d+") do + table.insert(child_pids, tonumber(pid_str)) + end + + -- Track the shell + terminal.track_terminal_pid(job_id) + + -- Verify shell and children are running + assert.is_true(process_exists(shell_pid), "Shell should be running before cleanup") + for _, child_pid in ipairs(child_pids) do + assert.is_true(process_exists(child_pid), "Child " .. child_pid .. " should be running before cleanup") + end + + -- Run cleanup + terminal.cleanup_all() + + -- Verify shell is dead + local shell_died = wait_for_process_death(shell_pid, 2000) + assert.is_true(shell_died, "Shell process should have been killed") + + -- Verify children are dead + for _, child_pid in ipairs(child_pids) do + local child_died = wait_for_process_death(child_pid, 2000) + assert.is_true(child_died, "Child process " .. child_pid .. " should have been killed") + end + end) + + it("should handle untracked processes gracefully", function() + -- Spawn a process but DON'T track it + local job_id = spawn_test_process() + local pid = get_pid(job_id) + assert.is_truthy(pid, "Failed to get PID") + + -- Verify running + assert.is_true(process_exists(pid), "Process should be running") + + -- Run cleanup (nothing tracked) + terminal.cleanup_all() + + -- Process should still be running (we didn't track it) + assert.is_true(process_exists(pid), "Untracked process should NOT be killed") + + -- Cleanup: kill it manually + pcall(vim.fn.jobstop, job_id) + end) + + it("should clear tracking after cleanup", function() + -- Spawn and track a process + local job_id = spawn_test_process() + terminal.track_terminal_pid(job_id) + + -- First cleanup + terminal.cleanup_all() + + -- Spawn another process + local job_id2 = spawn_test_process() + local pid2 = get_pid(job_id2) + + -- DON'T track it + + -- Second cleanup should not affect untracked process + terminal.cleanup_all() + + -- Process 2 should still be running + assert.is_true(process_exists(pid2), "Untracked process should survive cleanup") + + -- Cleanup + pcall(vim.fn.jobstop, job_id2) + end) + end) + + describe("defense-in-depth recovery with real processes", function() + it("should recover and kill processes from terminal buffers", function() + -- Create a real terminal buffer with a process + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_option_value("buftype", "terminal", { buf = buf }) + + -- Start a terminal job in this buffer + vim.api.nvim_buf_call(buf, function() + vim.fn.termopen("sleep 300", { + on_exit = function() end, + }) + end) + + -- Get the job_id from the buffer + local ok, job_id = pcall(vim.api.nvim_buf_get_var, buf, "terminal_job_id") + assert.is_true(ok, "Should have terminal_job_id") + assert.is_truthy(job_id, "Job ID should be set") + + local pid = get_pid(job_id) + assert.is_truthy(pid, "Should have valid PID") + assert.is_true(process_exists(pid), "Process should be running") + + -- DON'T track it via track_terminal_pid - let defense-in-depth find it + + -- Run cleanup - should recover PID from buffer + terminal.cleanup_all() + + -- Process should be dead + local died = wait_for_process_death(pid, 2000) + assert.is_true(died, "Process should have been killed via defense-in-depth recovery") + + -- Cleanup buffer + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + end) +end) diff --git a/tests/integration/test_cleanup_real.lua b/tests/integration/test_cleanup_real.lua new file mode 100644 index 00000000..c927e83f --- /dev/null +++ b/tests/integration/test_cleanup_real.lua @@ -0,0 +1,289 @@ +---Real integration test for terminal cleanup +---Run with: nvim --headless -u tests/minimal_init.lua -c "luafile tests/integration/test_cleanup_real.lua" + +local function log(msg) + print("[TEST] " .. msg) +end + +local function process_exists(pid) + if not pid or pid <= 0 then + return false + end + local result = os.execute("kill -0 " .. pid .. " 2>/dev/null") + return result == true or result == 0 +end + +local function wait_for_death(pid, timeout_ms) + timeout_ms = timeout_ms or 2000 + local start = vim.loop.now() + local iterations = 0 + while vim.loop.now() - start < timeout_ms do + if not process_exists(pid) then + return true + end + iterations = iterations + 1 + -- Use vim.wait instead of vim.loop.sleep for better compatibility + vim.wait(50, function() + return false + end) + if iterations > 100 then + -- Safety: don't loop forever + break + end + end + return not process_exists(pid) +end + +local function test_single_process_cleanup() + log("=== Test: Single process cleanup ===") + + -- Clear tracking + _G._claudecode_tracked_pids = {} + package.loaded["claudecode.terminal"] = nil + local terminal = require("claudecode.terminal") + + -- Spawn a process + local job_id = vim.fn.jobstart({ "sleep", "300" }, { detach = false }) + if not job_id or job_id <= 0 then + log("FAIL: Could not spawn process") + return false + end + + local pid = vim.fn.jobpid(job_id) + log("Spawned process: job_id=" .. job_id .. ", pid=" .. pid) + + if not process_exists(pid) then + log("FAIL: Process not running after spawn") + return false + end + + -- Track it + terminal.track_terminal_pid(job_id) + log("Tracked PID") + + -- Cleanup + terminal.cleanup_all() + log("Called cleanup_all()") + + -- Verify death + if wait_for_death(pid, 2000) then + log("PASS: Process was killed") + return true + else + log("FAIL: Process still running after cleanup") + vim.fn.jobstop(job_id) -- Manual cleanup + return false + end +end + +local function test_multiple_processes_cleanup() + log("=== Test: Multiple processes cleanup ===") + + _G._claudecode_tracked_pids = {} + package.loaded["claudecode.terminal"] = nil + local terminal = require("claudecode.terminal") + + local pids = {} + local jobs = {} + + -- Spawn 3 processes + for i = 1, 3 do + local job_id = vim.fn.jobstart({ "sleep", "300" }, { detach = false }) + if not job_id or job_id <= 0 then + log("FAIL: Could not spawn process " .. i) + return false + end + + local pid = vim.fn.jobpid(job_id) + terminal.track_terminal_pid(job_id) + + table.insert(jobs, job_id) + table.insert(pids, pid) + log("Spawned process " .. i .. ": pid=" .. pid) + end + + -- Cleanup + terminal.cleanup_all() + log("Called cleanup_all()") + + -- Verify all dead + local all_dead = true + for i, pid in ipairs(pids) do + if wait_for_death(pid, 2000) then + log("Process " .. i .. " (pid=" .. pid .. "): KILLED") + else + log("Process " .. i .. " (pid=" .. pid .. "): STILL RUNNING - FAIL") + all_dead = false + vim.fn.jobstop(jobs[i]) + end + end + + if all_dead then + log("PASS: All processes killed") + return true + else + log("FAIL: Some processes survived") + return false + end +end + +local function test_child_process_cleanup() + log("=== Test: Child process cleanup (pkill -P) ===") + + _G._claudecode_tracked_pids = {} + package.loaded["claudecode.terminal"] = nil + local terminal = require("claudecode.terminal") + + -- Spawn shell with child processes + local job_id = vim.fn.jobstart({ "sh", "-c", "sleep 300 & sleep 300 & wait" }, { detach = false }) + if not job_id or job_id <= 0 then + log("FAIL: Could not spawn shell") + return false + end + + local shell_pid = vim.fn.jobpid(job_id) + log("Shell pid=" .. shell_pid) + + -- Wait for children to spawn + vim.loop.sleep(200) + + -- Find children + local handle = io.popen("pgrep -P " .. shell_pid .. " 2>/dev/null") + local children_str = handle:read("*a") + handle:close() + + local child_pids = {} + for pid_str in children_str:gmatch("%d+") do + table.insert(child_pids, tonumber(pid_str)) + end + log("Found " .. #child_pids .. " children: " .. children_str:gsub("\n", ", ")) + + -- Track shell + terminal.track_terminal_pid(job_id) + + -- Cleanup + terminal.cleanup_all() + log("Called cleanup_all()") + + -- Verify shell dead + local shell_dead = wait_for_death(shell_pid, 2000) + if shell_dead then + log("Shell (pid=" .. shell_pid .. "): KILLED") + else + log("Shell (pid=" .. shell_pid .. "): STILL RUNNING - FAIL") + end + + -- Verify children dead + local all_children_dead = true + for _, child_pid in ipairs(child_pids) do + if wait_for_death(child_pid, 2000) then + log("Child (pid=" .. child_pid .. "): KILLED") + else + log("Child (pid=" .. child_pid .. "): STILL RUNNING - FAIL") + all_children_dead = false + pcall(function() + os.execute("kill -9 " .. child_pid .. " 2>/dev/null") + end) + end + end + + if shell_dead and all_children_dead then + log("PASS: Shell and all children killed") + return true + else + log("FAIL: Some processes survived") + vim.fn.jobstop(job_id) + return false + end +end + +local function test_defense_in_depth() + log("=== Test: Defense-in-depth (untracked terminal buffer) ===") + + _G._claudecode_tracked_pids = {} + package.loaded["claudecode.terminal"] = nil + local terminal = require("claudecode.terminal") + + -- Create a real terminal buffer using termopen + local buf = vim.api.nvim_create_buf(false, true) + + -- Start terminal in buffer using termopen (this sets buftype automatically) + local job_id + vim.api.nvim_buf_call(buf, function() + job_id = vim.fn.termopen("sleep 300", { + on_exit = function() end, + }) + end) + + if not job_id or job_id <= 0 then + log("FAIL: Could not start terminal") + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + return false + end + + local pid = vim.fn.jobpid(job_id) + log("Terminal: job_id=" .. job_id .. ", pid=" .. pid) + + -- Verify it's a terminal buffer + local buftype = vim.api.nvim_get_option_value("buftype", { buf = buf }) + log("Buffer type: " .. buftype) + + if buftype ~= "terminal" then + log("FAIL: Buffer is not a terminal") + vim.fn.jobstop(job_id) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + return false + end + + -- DON'T track it - let defense-in-depth find it + log("NOT tracking PID - testing defense-in-depth recovery") + + -- Cleanup should find it via buffer scan + terminal.cleanup_all() + log("Called cleanup_all()") + + -- Verify death + if wait_for_death(pid, 2000) then + log("PASS: Untracked terminal process was killed via defense-in-depth") + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + return true + else + log("FAIL: Untracked process survived - defense-in-depth didn't work") + vim.fn.jobstop(job_id) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + return false + end +end + +-- Run all tests +log("========================================") +log("Starting Terminal Cleanup Integration Tests") +log("========================================") + +local results = {} +table.insert(results, { "Single process", test_single_process_cleanup() }) +table.insert(results, { "Multiple processes", test_multiple_processes_cleanup() }) +table.insert(results, { "Child processes", test_child_process_cleanup() }) +table.insert(results, { "Defense-in-depth", test_defense_in_depth() }) + +log("========================================") +log("RESULTS:") +log("========================================") + +local all_passed = true +for _, r in ipairs(results) do + local status = r[2] and "PASS" or "FAIL" + log(r[1] .. ": " .. status) + if not r[2] then + all_passed = false + end +end + +log("========================================") +if all_passed then + log("ALL TESTS PASSED") + vim.cmd("qa!") +else + log("SOME TESTS FAILED") + vim.cmd("cq!") -- Exit with error code +end diff --git a/tests/unit/selection_win_enter_spec.lua b/tests/unit/selection_win_enter_spec.lua new file mode 100644 index 00000000..6f0db7f7 --- /dev/null +++ b/tests/unit/selection_win_enter_spec.lua @@ -0,0 +1,440 @@ +-- luacheck: globals expect +require("tests.busted_setup") + +describe("Selection WinEnter event handling", function() + local selection_module + local mock_server + local mock_vim + + local function setup_mocks() + package.loaded["claudecode.selection"] = nil + package.loaded["claudecode.logger"] = nil + package.loaded["claudecode.terminal"] = nil + package.loaded["claudecode.session"] = nil + + -- Mock logger + package.loaded["claudecode.logger"] = { + debug = function() end, + warn = function() end, + error = function() end, + } + + -- Mock terminal + package.loaded["claudecode.terminal"] = { + get_active_terminal_bufnr = function() + return nil -- No active terminal by default + end, + } + + -- Mock session manager + package.loaded["claudecode.session"] = { + get_active_session_id = function() + return nil + end, + update_selection = function() end, + get_selection = function() + return nil + end, + } + + -- Extend the existing vim mock + mock_vim = _G.vim or {} + + -- Track defer_fn calls + mock_vim._defer_fn_calls = {} + mock_vim.defer_fn = function(fn, timeout) + table.insert(mock_vim._defer_fn_calls, { fn = fn, timeout = timeout }) + -- Execute immediately for testing + fn() + end + + -- Track timer operations + mock_vim.loop = mock_vim.loop or {} + mock_vim._timers = {} + mock_vim._timer_stops = {} + + mock_vim.loop.timer_stop = function(timer) + table.insert(mock_vim._timer_stops, timer) + return true + end + + mock_vim.loop.new_timer = function() + local timer = { + start = function() end, + stop = function() end, + close = function() end, + } + table.insert(mock_vim._timers, timer) + return timer + end + + mock_vim.loop.now = function() + return os.time() * 1000 + end + + -- Mock API functions + mock_vim.api = mock_vim.api or {} + mock_vim._autocmd_events = {} + + mock_vim.api.nvim_create_augroup = function(name, opts) + return name + end + + mock_vim.api.nvim_create_autocmd = function(events, opts) + local events_list = type(events) == "table" and events or { events } + for _, event in ipairs(events_list) do + mock_vim._autocmd_events[event] = opts.callback + end + return 1 + end + + mock_vim.api.nvim_clear_autocmds = function() end + + mock_vim.api.nvim_get_mode = function() + return { mode = "n" } -- Default to normal mode + end + + mock_vim.api.nvim_get_current_buf = function() + return 1 + end + + mock_vim.api.nvim_buf_get_name = function(bufnr) + return "/test/file.lua" + end + + mock_vim.api.nvim_win_get_cursor = function(winid) + return { 1, 0 } + end + + mock_vim.schedule_wrap = function(fn) + return fn + end + + mock_vim.schedule = function(fn) + fn() + end + + mock_vim.deepcopy = function(t) + if type(t) ~= "table" then + return t + end + local copy = {} + for k, v in pairs(t) do + copy[k] = mock_vim.deepcopy(v) + end + return copy + end + + _G.vim = mock_vim + end + + before_each(function() + setup_mocks() + + mock_server = { + broadcast = function() + return true + end, + send_to_active_session = function() + return false + end, + } + + selection_module = require("claudecode.selection") + end) + + after_each(function() + if selection_module and selection_module.state.tracking_enabled then + selection_module.disable() + end + mock_vim._defer_fn_calls = {} + mock_vim._timer_stops = {} + mock_vim._autocmd_events = {} + end) + + describe("WinEnter autocommand registration", function() + it("should register WinEnter autocommand when selection tracking is enabled", function() + selection_module.enable(mock_server, 50) + + expect(mock_vim._autocmd_events["WinEnter"]).not_to_be_nil() + end) + + it("should not have WinEnter autocommand before enabling", function() + -- The autocommands are created inside _create_autocommands which is called by enable + expect(mock_vim._autocmd_events["WinEnter"]).to_be_nil() + end) + + it("should register all expected autocommands", function() + selection_module.enable(mock_server, 50) + + expect(mock_vim._autocmd_events["CursorMoved"]).not_to_be_nil() + expect(mock_vim._autocmd_events["CursorMovedI"]).not_to_be_nil() + expect(mock_vim._autocmd_events["BufEnter"]).not_to_be_nil() + expect(mock_vim._autocmd_events["WinEnter"]).not_to_be_nil() + expect(mock_vim._autocmd_events["ModeChanged"]).not_to_be_nil() + expect(mock_vim._autocmd_events["TextChanged"]).not_to_be_nil() + end) + end) + + describe("on_win_enter handler", function() + it("should call update_selection when tracking is enabled", function() + selection_module.enable(mock_server, 50) + + local update_called = false + local original_update = selection_module.update_selection + selection_module.update_selection = function() + update_called = true + original_update() + end + + selection_module.on_win_enter() + + expect(update_called).to_be_true() + + selection_module.update_selection = original_update + end) + + it("should not call update_selection when tracking is disabled", function() + selection_module.enable(mock_server, 50) + selection_module.disable() + + local update_called = false + local original_update = selection_module.update_selection + selection_module.update_selection = function() + update_called = true + end + + selection_module.on_win_enter() + + expect(update_called).to_be_false() + + selection_module.update_selection = original_update + end) + + it("should cancel pending debounce timer", function() + selection_module.enable(mock_server, 50) + + -- Simulate a pending debounce timer + local mock_timer = { stopped = false } + selection_module.state.debounce_timer = mock_timer + + selection_module.on_win_enter() + + -- Check that timer_stop was called + expect(#mock_vim._timer_stops > 0).to_be_true() + expect(selection_module.state.debounce_timer).to_be_nil() + end) + + it("should use 10ms delay via defer_fn", function() + selection_module.enable(mock_server, 50) + + mock_vim._defer_fn_calls = {} + selection_module.on_win_enter() + + expect(#mock_vim._defer_fn_calls > 0).to_be_true() + expect(mock_vim._defer_fn_calls[1].timeout).to_be(10) + end) + end) + + describe("WinEnter callback invocation", function() + it("should invoke on_win_enter when WinEnter event fires", function() + selection_module.enable(mock_server, 50) + + local on_win_enter_called = false + local original = selection_module.on_win_enter + selection_module.on_win_enter = function() + on_win_enter_called = true + original() + end + + -- Simulate WinEnter event + local callback = mock_vim._autocmd_events["WinEnter"] + expect(callback).not_to_be_nil() + callback() + + expect(on_win_enter_called).to_be_true() + + selection_module.on_win_enter = original + end) + end) + + describe("keyboard navigation scenarios", function() + it("should update file reference when navigating to window with different buffer", function() + selection_module.enable(mock_server, 50) + + local selections_sent = {} + mock_server.broadcast = function(event, data) + if event == "selection_changed" then + table.insert(selections_sent, data) + end + return true + end + + -- Simulate first window with file1 + mock_vim.api.nvim_buf_get_name = function() + return "/test/file1.lua" + end + selection_module.on_win_enter() + + -- Simulate navigating to second window with file2 + mock_vim.api.nvim_buf_get_name = function() + return "/test/file2.lua" + end + selection_module.on_win_enter() + + -- Should have sent updates for both files + expect(#selections_sent >= 1).to_be_true() + local last_selection = selections_sent[#selections_sent] + expect(last_selection.filePath).to_be("/test/file2.lua") + end) + + it("should update when navigating to window with same buffer but different cursor", function() + selection_module.enable(mock_server, 50) + + local selections_sent = {} + mock_server.broadcast = function(event, data) + if event == "selection_changed" then + table.insert(selections_sent, data) + end + return true + end + + -- First window position + mock_vim.api.nvim_win_get_cursor = function() + return { 1, 0 } + end + selection_module.on_win_enter() + + -- Second window position (same file, different cursor) + mock_vim.api.nvim_win_get_cursor = function() + return { 10, 5 } + end + selection_module.on_win_enter() + + -- Should have sent updates for position changes + expect(#selections_sent >= 1).to_be_true() + end) + + it("should not cause race conditions with rapid window switching", function() + selection_module.enable(mock_server, 50) + + local error_occurred = false + local original_update = selection_module.update_selection + + selection_module.update_selection = function() + if error_occurred then + return + end + local success = pcall(original_update) + if not success then + error_occurred = true + end + end + + -- Simulate rapid window switching + for i = 1, 20 do + mock_vim.api.nvim_buf_get_name = function() + return "/test/file" .. i .. ".lua" + end + selection_module.on_win_enter() + end + + expect(error_occurred).to_be_false() + + selection_module.update_selection = original_update + end) + end) + + describe("integration with existing handlers", function() + it("should not interfere with mouse handler behavior", function() + selection_module.enable(mock_server, 50) + + local selections_sent = {} + mock_server.broadcast = function(event, data) + if event == "selection_changed" then + table.insert(selections_sent, data) + end + return true + end + + -- Simulate WinEnter (keyboard navigation) + selection_module.on_win_enter() + local count_after_win_enter = #selections_sent + + -- Simulate update_selection directly (like mouse handler does) + selection_module.update_selection() + local count_after_mouse = #selections_sent + + -- Both should work independently + expect(count_after_win_enter >= 1).to_be_true() + -- Mouse update might not send if selection hasn't changed + expect(count_after_mouse >= count_after_win_enter).to_be_true() + end) + + it("should work alongside BufEnter events", function() + selection_module.enable(mock_server, 50) + + -- Both WinEnter and BufEnter should have callbacks + expect(mock_vim._autocmd_events["WinEnter"]).not_to_be_nil() + expect(mock_vim._autocmd_events["BufEnter"]).not_to_be_nil() + + -- Both should be callable without errors + local success1 = pcall(mock_vim._autocmd_events["WinEnter"]) + local success2 = pcall(mock_vim._autocmd_events["BufEnter"]) + + expect(success1).to_be_true() + expect(success2).to_be_true() + end) + end) + + describe("terminal buffer handling", function() + it("should skip update when entering Claude terminal window", function() + selection_module.enable(mock_server, 50) + + -- Mock terminal to return a Claude terminal buffer + package.loaded["claudecode.terminal"].get_active_terminal_bufnr = function() + return 1 -- Current buffer is the terminal + end + + local selections_sent = {} + mock_server.broadcast = function(event, data) + if event == "selection_changed" then + table.insert(selections_sent, data) + end + return true + end + + -- Clear any previous selections + selection_module.state.latest_selection = nil + + selection_module.on_win_enter() + + -- Should not send selection update for terminal + expect(#selections_sent).to_be(0) + end) + + it("should skip update when buffer name matches term://...claude pattern", function() + selection_module.enable(mock_server, 50) + + mock_vim.api.nvim_buf_get_name = function() + return "term://~/claude" + end + + local selections_sent = {} + mock_server.broadcast = function(event, data) + if event == "selection_changed" then + table.insert(selections_sent, data) + end + return true + end + + -- Clear any previous selections + selection_module.state.latest_selection = nil + + selection_module.on_win_enter() + + -- Should not send selection update for claude terminal + expect(#selections_sent).to_be(0) + end) + end) +end) diff --git a/tests/unit/session_spec.lua b/tests/unit/session_spec.lua new file mode 100644 index 00000000..42b51bac --- /dev/null +++ b/tests/unit/session_spec.lua @@ -0,0 +1,440 @@ +---Tests for the session manager module. +---@module 'tests.unit.session_spec' + +-- Setup test environment +require("tests.busted_setup") + +describe("Session Manager", function() + local session_manager + + before_each(function() + -- Reset module state before each test + package.loaded["claudecode.session"] = nil + session_manager = require("claudecode.session") + session_manager.reset() + end) + + describe("create_session", function() + it("should create a new session with unique ID", function() + local session_id = session_manager.create_session() + + assert.is_string(session_id) + assert.is_not_nil(session_id) + assert.truthy(session_id:match("^session_")) + end) + + it("should create sessions with unique IDs", function() + local id1 = session_manager.create_session() + local id2 = session_manager.create_session() + local id3 = session_manager.create_session() + + assert.are_not.equal(id1, id2) + assert.are_not.equal(id2, id3) + assert.are_not.equal(id1, id3) + end) + + it("should set first session as active", function() + local session_id = session_manager.create_session() + + assert.are.equal(session_id, session_manager.get_active_session_id()) + end) + + it("should not change active session when creating additional sessions", function() + local first_id = session_manager.create_session() + session_manager.create_session() + session_manager.create_session() + + assert.are.equal(first_id, session_manager.get_active_session_id()) + end) + + it("should accept optional name parameter", function() + local session_id = session_manager.create_session({ name = "Test Session" }) + local session = session_manager.get_session(session_id) + + assert.are.equal("Test Session", session.name) + end) + + it("should generate default name if not provided", function() + local session_id = session_manager.create_session() + local session = session_manager.get_session(session_id) + + assert.is_string(session.name) + assert.truthy(session.name:match("^Session %d+$")) + end) + end) + + describe("destroy_session", function() + it("should remove session from sessions table", function() + local session_id = session_manager.create_session() + assert.is_not_nil(session_manager.get_session(session_id)) + + local result = session_manager.destroy_session(session_id) + + assert.is_true(result) + assert.is_nil(session_manager.get_session(session_id)) + end) + + it("should return false for non-existent session", function() + local result = session_manager.destroy_session("non_existent") + + assert.is_false(result) + end) + + it("should switch active session when destroying active session", function() + local id1 = session_manager.create_session() + local id2 = session_manager.create_session() + + assert.are.equal(id1, session_manager.get_active_session_id()) + + session_manager.destroy_session(id1) + + assert.are.equal(id2, session_manager.get_active_session_id()) + end) + + it("should clear active session when destroying last session", function() + local session_id = session_manager.create_session() + + session_manager.destroy_session(session_id) + + assert.is_nil(session_manager.get_active_session_id()) + end) + end) + + describe("get_session", function() + it("should return session by ID", function() + local session_id = session_manager.create_session() + local session = session_manager.get_session(session_id) + + assert.is_table(session) + assert.are.equal(session_id, session.id) + end) + + it("should return nil for non-existent session", function() + local session = session_manager.get_session("non_existent") + + assert.is_nil(session) + end) + end) + + describe("set_active_session", function() + it("should change active session", function() + local id1 = session_manager.create_session() + local id2 = session_manager.create_session() + + assert.are.equal(id1, session_manager.get_active_session_id()) + + local result = session_manager.set_active_session(id2) + + assert.is_true(result) + assert.are.equal(id2, session_manager.get_active_session_id()) + end) + + it("should return false for non-existent session", function() + session_manager.create_session() + + local result = session_manager.set_active_session("non_existent") + + assert.is_false(result) + end) + end) + + describe("list_sessions", function() + it("should return empty array when no sessions", function() + local sessions = session_manager.list_sessions() + + assert.is_table(sessions) + assert.are.equal(0, #sessions) + end) + + it("should return all sessions", function() + session_manager.create_session() + session_manager.create_session() + session_manager.create_session() + + local sessions = session_manager.list_sessions() + + assert.are.equal(3, #sessions) + end) + + it("should return sessions sorted by creation time", function() + local id1 = session_manager.create_session() + local id2 = session_manager.create_session() + local id3 = session_manager.create_session() + + local sessions = session_manager.list_sessions() + + -- Just verify all sessions are returned (order may vary if timestamps are equal) + local ids = {} + for _, s in ipairs(sessions) do + ids[s.id] = true + end + assert.is_true(ids[id1]) + assert.is_true(ids[id2]) + assert.is_true(ids[id3]) + + -- Verify sorted by created_at (ascending) + for i = 1, #sessions - 1 do + assert.is_true(sessions[i].created_at <= sessions[i + 1].created_at) + end + end) + end) + + describe("get_session_count", function() + it("should return 0 when no sessions", function() + assert.are.equal(0, session_manager.get_session_count()) + end) + + it("should return correct count", function() + session_manager.create_session() + session_manager.create_session() + + assert.are.equal(2, session_manager.get_session_count()) + + session_manager.create_session() + + assert.are.equal(3, session_manager.get_session_count()) + end) + end) + + describe("client binding", function() + it("should bind client to session", function() + local session_id = session_manager.create_session() + + local result = session_manager.bind_client(session_id, "client_123") + + assert.is_true(result) + local session = session_manager.get_session(session_id) + assert.are.equal("client_123", session.client_id) + end) + + it("should find session by client ID", function() + local session_id = session_manager.create_session() + session_manager.bind_client(session_id, "client_123") + + local found_session = session_manager.find_session_by_client("client_123") + + assert.is_not_nil(found_session) + assert.are.equal(session_id, found_session.id) + end) + + it("should unbind client from session", function() + local session_id = session_manager.create_session() + session_manager.bind_client(session_id, "client_123") + + local result = session_manager.unbind_client("client_123") + + assert.is_true(result) + local session = session_manager.get_session(session_id) + assert.is_nil(session.client_id) + end) + + it("should return false when binding to non-existent session", function() + local result = session_manager.bind_client("non_existent", "client_123") + + assert.is_false(result) + end) + + it("should return false when unbinding non-bound client", function() + local result = session_manager.unbind_client("non_existent_client") + + assert.is_false(result) + end) + end) + + describe("terminal info", function() + it("should update terminal info for session", function() + local session_id = session_manager.create_session() + + session_manager.update_terminal_info(session_id, { + bufnr = 42, + winid = 100, + jobid = 200, + }) + + local session = session_manager.get_session(session_id) + assert.are.equal(42, session.terminal_bufnr) + assert.are.equal(100, session.terminal_winid) + assert.are.equal(200, session.terminal_jobid) + end) + + it("should find session by buffer number", function() + local session_id = session_manager.create_session() + session_manager.update_terminal_info(session_id, { bufnr = 42 }) + + local found_session = session_manager.find_session_by_bufnr(42) + + assert.is_not_nil(found_session) + assert.are.equal(session_id, found_session.id) + end) + + it("should return nil when buffer not found", function() + session_manager.create_session() + + local found_session = session_manager.find_session_by_bufnr(999) + + assert.is_nil(found_session) + end) + end) + + describe("selection tracking", function() + it("should update session selection", function() + local session_id = session_manager.create_session() + local selection = { text = "test", filePath = "/test.lua" } + + session_manager.update_selection(session_id, selection) + + local stored_selection = session_manager.get_selection(session_id) + assert.are.same(selection, stored_selection) + end) + + it("should return nil for session without selection", function() + local session_id = session_manager.create_session() + + local selection = session_manager.get_selection(session_id) + + assert.is_nil(selection) + end) + end) + + describe("mention queue", function() + it("should queue mentions for session", function() + local session_id = session_manager.create_session() + local mention = { file = "/test.lua", line = 10 } + + session_manager.queue_mention(session_id, mention) + + local session = session_manager.get_session(session_id) + assert.are.equal(1, #session.mention_queue) + end) + + it("should flush mention queue", function() + local session_id = session_manager.create_session() + session_manager.queue_mention(session_id, { file = "/a.lua" }) + session_manager.queue_mention(session_id, { file = "/b.lua" }) + + local mentions = session_manager.flush_mention_queue(session_id) + + assert.are.equal(2, #mentions) + + -- Queue should be empty after flush + local session = session_manager.get_session(session_id) + assert.are.equal(0, #session.mention_queue) + end) + end) + + describe("ensure_session", function() + it("should return existing active session", function() + local original_id = session_manager.create_session() + + local session_id = session_manager.ensure_session() + + assert.are.equal(original_id, session_id) + end) + + it("should create new session if none exists", function() + local session_id = session_manager.ensure_session() + + assert.is_string(session_id) + assert.is_not_nil(session_manager.get_session(session_id)) + end) + end) + + describe("reset", function() + it("should clear all sessions", function() + session_manager.create_session() + session_manager.create_session() + + session_manager.reset() + + assert.are.equal(0, session_manager.get_session_count()) + assert.is_nil(session_manager.get_active_session_id()) + end) + end) + + describe("update_session_name", function() + it("should update session name", function() + local session_id = session_manager.create_session() + + session_manager.update_session_name(session_id, "New Name") + + local session = session_manager.get_session(session_id) + assert.are.equal("New Name", session.name) + end) + + it("should strip Claude - prefix", function() + local session_id = session_manager.create_session() + + session_manager.update_session_name(session_id, "Claude - implement vim mode") + + local session = session_manager.get_session(session_id) + assert.are.equal("implement vim mode", session.name) + end) + + it("should strip claude - prefix (lowercase)", function() + local session_id = session_manager.create_session() + + session_manager.update_session_name(session_id, "claude - fix bug") + + local session = session_manager.get_session(session_id) + assert.are.equal("fix bug", session.name) + end) + + it("should trim whitespace", function() + local session_id = session_manager.create_session() + + session_manager.update_session_name(session_id, " trimmed name ") + + local session = session_manager.get_session(session_id) + assert.are.equal("trimmed name", session.name) + end) + + it("should limit name length to 100 characters", function() + local session_id = session_manager.create_session() + local long_name = string.rep("x", 150) + + session_manager.update_session_name(session_id, long_name) + + local session = session_manager.get_session(session_id) + assert.are.equal(100, #session.name) + assert.truthy(session.name:match("%.%.%.$")) + end) + + it("should not update if name is empty", function() + local session_id = session_manager.create_session() + local original_name = session_manager.get_session(session_id).name + + session_manager.update_session_name(session_id, "") + + local session = session_manager.get_session(session_id) + assert.are.equal(original_name, session.name) + end) + + it("should not update if name is unchanged", function() + local session_id = session_manager.create_session() + session_manager.update_session_name(session_id, "Test Name") + + -- This should not trigger an update (same name) + session_manager.update_session_name(session_id, "Test Name") + + local session = session_manager.get_session(session_id) + assert.are.equal("Test Name", session.name) + end) + + it("should not error for non-existent session", function() + assert.has_no.errors(function() + session_manager.update_session_name("non_existent", "New Name") + end) + end) + + it("should not update if only Claude prefix remains after stripping", function() + local session_id = session_manager.create_session() + local original_name = session_manager.get_session(session_id).name + + -- "Claude - " stripped leaves empty string + session_manager.update_session_name(session_id, "Claude - ") + + local session = session_manager.get_session(session_id) + assert.are.equal(original_name, session.name) + end) + end) +end) diff --git a/tests/unit/terminal/osc_handler_spec.lua b/tests/unit/terminal/osc_handler_spec.lua new file mode 100644 index 00000000..0e546fb4 --- /dev/null +++ b/tests/unit/terminal/osc_handler_spec.lua @@ -0,0 +1,171 @@ +---Tests for the OSC handler module. +---@module 'tests.unit.terminal.osc_handler_spec' + +-- Setup test environment +require("tests.busted_setup") + +describe("OSC Handler", function() + local osc_handler + + before_each(function() + -- Reset module state before each test + package.loaded["claudecode.terminal.osc_handler"] = nil + osc_handler = require("claudecode.terminal.osc_handler") + osc_handler._reset() + end) + + describe("parse_osc_title", function() + it("should return nil for nil input", function() + local result = osc_handler.parse_osc_title(nil) + assert.is_nil(result) + end) + + it("should return nil for empty string", function() + local result = osc_handler.parse_osc_title("") + assert.is_nil(result) + end) + + it("should parse OSC 0 with BEL terminator", function() + -- OSC 0: ESC ] 0 ; title BEL + local data = "\027]0;My Title\007" + local result = osc_handler.parse_osc_title(data) + assert.are.equal("My Title", result) + end) + + it("should parse OSC 2 with BEL terminator", function() + -- OSC 2: ESC ] 2 ; title BEL + local data = "\027]2;Window Title\007" + local result = osc_handler.parse_osc_title(data) + assert.are.equal("Window Title", result) + end) + + it("should parse OSC 0 with ST terminator", function() + -- OSC 0: ESC ] 0 ; title ESC \ + local data = "\027]0;My Title\027\\" + local result = osc_handler.parse_osc_title(data) + assert.are.equal("My Title", result) + end) + + it("should parse OSC 2 with ST terminator", function() + -- OSC 2: ESC ] 2 ; title ESC \ + local data = "\027]2;Window Title\027\\" + local result = osc_handler.parse_osc_title(data) + assert.are.equal("Window Title", result) + end) + + it("should handle Claude-specific title format", function() + local data = "\027]2;Claude - implement vim mode\007" + local result = osc_handler.parse_osc_title(data) + assert.are.equal("Claude - implement vim mode", result) + end) + + it("should return nil for non-OSC sequences", function() + local result = osc_handler.parse_osc_title("Just plain text") + assert.is_nil(result) + end) + + it("should return nil for other OSC types (not 0 or 2)", function() + -- OSC 7 is for working directory, not title + local data = "\027]7;file:///path\007" + local result = osc_handler.parse_osc_title(data) + assert.is_nil(result) + end) + + it("should handle empty title", function() + local data = "\027]2;\007" + local result = osc_handler.parse_osc_title(data) + assert.is_nil(result) + end) + + it("should handle title with special characters", function() + local data = "\027]2;Project: my-app (dev)\007" + local result = osc_handler.parse_osc_title(data) + assert.are.equal("Project: my-app (dev)", result) + end) + + it("should handle title without ESC prefix", function() + -- Some terminals may strip the ESC prefix + local data = "]2;My Title" + local result = osc_handler.parse_osc_title(data) + assert.are.equal("My Title", result) + end) + + it("should trim whitespace from title", function() + local data = "\027]2; spaced title \007" + local result = osc_handler.parse_osc_title(data) + assert.are.equal("spaced title", result) + end) + end) + + describe("clean_title", function() + it("should strip Claude - prefix", function() + local result = osc_handler.clean_title("Claude - my project") + assert.are.equal("my project", result) + end) + + it("should strip claude - prefix (lowercase)", function() + local result = osc_handler.clean_title("claude - my project") + assert.are.equal("my project", result) + end) + + it("should not strip Claude prefix without dash", function() + local result = osc_handler.clean_title("Claude project") + assert.are.equal("Claude project", result) + end) + + it("should trim whitespace", function() + local result = osc_handler.clean_title(" my title ") + assert.are.equal("my title", result) + end) + + it("should limit length to 100 characters", function() + local long_title = string.rep("a", 150) + local result = osc_handler.clean_title(long_title) + assert.are.equal(100, #result) + assert.truthy(result:match("%.%.%.$")) + end) + + it("should handle nil input", function() + local result = osc_handler.clean_title(nil) + assert.is_nil(result) + end) + end) + + describe("has_handler", function() + it("should return false for buffer without handler", function() + local result = osc_handler.has_handler(123) + assert.is_false(result) + end) + end) + + describe("_get_handler_count", function() + it("should return 0 when no handlers registered", function() + assert.are.equal(0, osc_handler._get_handler_count()) + end) + end) + + describe("_reset", function() + it("should clear all handlers", function() + -- Since we can't easily set up handlers without a real terminal, + -- we just verify reset doesn't error and maintains count at 0 + osc_handler._reset() + assert.are.equal(0, osc_handler._get_handler_count()) + end) + end) + + describe("cleanup_buffer_handler", function() + it("should not error when cleaning up non-existent handler", function() + -- Should not throw an error + assert.has_no.errors(function() + osc_handler.cleanup_buffer_handler(999) + end) + end) + + it("should be idempotent (double cleanup should not error)", function() + assert.has_no.errors(function() + osc_handler.cleanup_buffer_handler(123) + osc_handler.cleanup_buffer_handler(123) + end) + end) + end) +end) diff --git a/tests/unit/terminal_cleanup_spec.lua b/tests/unit/terminal_cleanup_spec.lua new file mode 100644 index 00000000..43174237 --- /dev/null +++ b/tests/unit/terminal_cleanup_spec.lua @@ -0,0 +1,456 @@ +---Tests for terminal cleanup functionality +---Ensures cleanup_all() properly kills Claude processes on Neovim exit + +describe("terminal cleanup_all", function() + local terminal + local mock_vim + + -- Track calls to vim functions + local jobstop_calls = {} + local system_calls = {} + + before_each(function() + -- Reset call tracking + jobstop_calls = {} + system_calls = {} + + -- Mock vim global + mock_vim = { + api = { + nvim_buf_is_valid = function(bufnr) + return bufnr and bufnr > 0 + end, + nvim_buf_get_var = function(bufnr, var_name) + if var_name == "terminal_job_id" then + return bufnr * 10 -- Return a predictable job_id based on bufnr + end + error("Unknown variable: " .. var_name) + end, + nvim_echo = function() end, -- Suppress debug output + nvim_create_augroup = function() + return 1 + end, + nvim_create_autocmd = function() + return 1 + end, + nvim_list_bufs = function() + return {} -- Default: no buffers + end, + nvim_get_option_value = function() + return "" + end, + }, + fn = { + jobpid = function(job_id) + -- Return a predictable PID based on job_id + return job_id * 100 + end, + jobstop = function(job_id) + table.insert(jobstop_calls, job_id) + return 1 + end, + system = function(cmd) + table.insert(system_calls, cmd) + return "" + end, + }, + log = { + levels = { + DEBUG = 1, + INFO = 2, + WARN = 3, + ERROR = 4, + }, + }, + loop = { + now = function() + return 12345 + end, + }, + } + + -- Install mock vim + _G.vim = mock_vim + + -- Clear all relevant modules first + package.loaded["claudecode.terminal"] = nil + package.loaded["claudecode.logger"] = nil + package.loaded["claudecode.server.init"] = nil + package.loaded["claudecode.terminal.osc_handler"] = nil + package.loaded["claudecode.session"] = nil + + -- Mock logger + package.loaded["claudecode.logger"] = { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + } + + -- Mock server module (required by terminal.lua) + package.loaded["claudecode.server.init"] = { + state = { port = 12345 }, + } + + -- Mock osc_handler module (required by terminal.lua) + package.loaded["claudecode.terminal.osc_handler"] = { + setup_buffer_handler = function() end, + cleanup_buffer_handler = function() end, + } + + -- Mock session manager module (required by terminal.lua) + package.loaded["claudecode.session"] = { + ensure_session = function() + return "session_1" + end, + get_session = function() + return nil + end, + destroy_session = function() end, + find_session_by_bufnr = function() + return nil + end, + list_sessions = function() + return {} + end, + get_session_count = function() + return 0 + end, + get_active_session_id = function() + return nil + end, + set_active_session = function() end, + update_terminal_info = function() end, + update_session_name = function() end, + } + + -- Clear global tracked PIDs + _G._claudecode_tracked_pids = {} + _G._claudecode_buffer_to_session = {} + + terminal = require("claudecode.terminal") + end) + + it("should do nothing when no PIDs are tracked", function() + terminal.cleanup_all() + + assert.are.same({}, jobstop_calls) + assert.are.same({}, system_calls) + end) + + it("should kill tracked PIDs", function() + -- Track a PID + terminal.track_terminal_pid(42) + + terminal.cleanup_all() + + -- Should have called jobstop with the job_id + assert.are.same({ 42 }, jobstop_calls) + + -- Should have called system with pkill command using the PID (42 * 100 = 4200) + assert.is_true(#system_calls >= 1) + local found_pkill = false + for _, cmd in ipairs(system_calls) do + if cmd:match("pkill %-TERM %-P 4200") then + found_pkill = true + end + end + assert.is_true(found_pkill, "Expected pkill -TERM -P 4200 command") + end) + + it("should kill multiple tracked PIDs", function() + -- Track multiple PIDs + terminal.track_terminal_pid(100) + terminal.track_terminal_pid(200) + terminal.track_terminal_pid(300) + + terminal.cleanup_all() + + -- Should have called jobstop for all jobs + assert.are.equal(3, #jobstop_calls) + + -- Should have pkill commands for all PIDs (100*100=10000, 200*100=20000, 300*100=30000) + local found_pids = { [10000] = false, [20000] = false, [30000] = false } + for _, cmd in ipairs(system_calls) do + for pid, _ in pairs(found_pids) do + if cmd:match("pkill %-TERM %-P " .. pid) then + found_pids[pid] = true + end + end + end + assert.is_true(found_pids[10000], "Expected pkill for PID 10000") + assert.is_true(found_pids[20000], "Expected pkill for PID 20000") + assert.is_true(found_pids[30000], "Expected pkill for PID 30000") + end) + + it("should untrack PIDs when terminal exits normally", function() + -- Track a PID + terminal.track_terminal_pid(42) + + -- Simulate terminal exiting normally + terminal.untrack_terminal_pid(42) + + -- Now cleanup should have nothing to do + terminal.cleanup_all() + + assert.are.same({}, jobstop_calls) + assert.are.same({}, system_calls) + end) + + it("should handle jobpid failure gracefully", function() + -- Make jobpid fail + mock_vim.fn.jobpid = function() + error("Job not found") + end + + -- track_terminal_pid should not error when jobpid fails + assert.has_no.errors(function() + terminal.track_terminal_pid(42) + end) + + -- cleanup_all should not error either + assert.has_no.errors(function() + terminal.cleanup_all() + end) + end) + + it("should handle jobpid returning invalid PID", function() + -- Make jobpid return 0 (invalid) + mock_vim.fn.jobpid = function() + return 0 + end + + -- track_terminal_pid should handle invalid PID gracefully + terminal.track_terminal_pid(42) + + -- cleanup_all should not have any PIDs to kill + terminal.cleanup_all() + + -- No system calls since PID was invalid + assert.are.same({}, system_calls) + end) + + it("should clear tracked PIDs after cleanup", function() + -- Track a PID + terminal.track_terminal_pid(42) + + -- First cleanup should kill it + terminal.cleanup_all() + assert.are.equal(1, #jobstop_calls) + + -- Reset tracking + jobstop_calls = {} + system_calls = {} + + -- Second cleanup should have nothing to do + terminal.cleanup_all() + assert.are.same({}, jobstop_calls) + assert.are.same({}, system_calls) + end) + + describe("defense-in-depth PID recovery", function() + it("should recover PIDs from session manager", function() + -- Mock session manager with sessions containing terminal_jobid + package.loaded["claudecode.session"] = { + list_sessions = function() + return { + { id = "session_1", terminal_jobid = 100 }, + { id = "session_2", terminal_jobid = 200 }, + } + end, + } + + -- PIDs are not tracked initially + terminal.cleanup_all() + + -- Should have recovered and killed both PIDs (100*100=10000, 200*100=20000) + assert.are.equal(2, #jobstop_calls) + + local found_pids = { [10000] = false, [20000] = false } + for _, cmd in ipairs(system_calls) do + for pid, _ in pairs(found_pids) do + if cmd:match("pkill %-TERM %-P " .. pid) then + found_pids[pid] = true + end + end + end + assert.is_true(found_pids[10000], "Expected pkill for recovered PID 10000") + assert.is_true(found_pids[20000], "Expected pkill for recovered PID 20000") + end) + + it("should recover PIDs from terminal buffers", function() + -- Mock nvim_list_bufs to return terminal buffers + mock_vim.api.nvim_list_bufs = function() + return { 1, 2, 3 } + end + + -- Mock nvim_get_option_value for buftype + mock_vim.api.nvim_get_option_value = function(opt, opts) + if opt == "buftype" then + -- Buffers 1 and 3 are terminals, buffer 2 is not + if opts.buf == 1 or opts.buf == 3 then + return "terminal" + end + return "" + end + return nil + end + + -- Mock nvim_buf_get_var to return terminal_job_id + mock_vim.api.nvim_buf_get_var = function(bufnr, var_name) + if var_name == "terminal_job_id" then + if bufnr == 1 then + return 300 + end + if bufnr == 3 then + return 400 + end + end + error("Unknown variable: " .. var_name) + end + + -- Mock session manager to return empty (no sessions) + package.loaded["claudecode.session"] = { + list_sessions = function() + return {} + end, + } + + terminal.cleanup_all() + + -- Should have recovered PIDs from terminal buffers (300*100=30000, 400*100=40000) + assert.are.equal(2, #jobstop_calls) + + local found_pids = { [30000] = false, [40000] = false } + for _, cmd in ipairs(system_calls) do + for pid, _ in pairs(found_pids) do + if cmd:match("pkill %-TERM %-P " .. pid) then + found_pids[pid] = true + end + end + end + assert.is_true(found_pids[30000], "Expected pkill for recovered PID 30000") + assert.is_true(found_pids[40000], "Expected pkill for recovered PID 40000") + end) + + it("should handle mixed tracked and recovered PIDs", function() + -- Track one PID directly + terminal.track_terminal_pid(42) + + -- Mock session manager with one additional session + package.loaded["claudecode.session"] = { + list_sessions = function() + return { + { id = "session_1", terminal_jobid = 100 }, + } + end, + } + + -- Mock nvim_list_bufs to return one terminal buffer + mock_vim.api.nvim_list_bufs = function() + return { 5 } + end + + mock_vim.api.nvim_get_option_value = function(opt, opts) + if opt == "buftype" and opts.buf == 5 then + return "terminal" + end + return "" + end + + -- Use different job_id for buffer to avoid duplicate + local original_buf_get_var = mock_vim.api.nvim_buf_get_var + mock_vim.api.nvim_buf_get_var = function(bufnr, var_name) + if var_name == "terminal_job_id" and bufnr == 5 then + return 200 + end + return original_buf_get_var(bufnr, var_name) + end + + terminal.cleanup_all() + + -- Should have killed all 3 PIDs: + -- - 42 (tracked directly) -> PID 4200 + -- - 100 (from session) -> PID 10000 + -- - 200 (from buffer) -> PID 20000 + assert.are.equal(3, #jobstop_calls) + + local found_pids = { [4200] = false, [10000] = false, [20000] = false } + for _, cmd in ipairs(system_calls) do + for pid, _ in pairs(found_pids) do + if cmd:match("pkill %-TERM %-P " .. pid) then + found_pids[pid] = true + end + end + end + assert.is_true(found_pids[4200], "Expected pkill for tracked PID 4200") + assert.is_true(found_pids[10000], "Expected pkill for session PID 10000") + assert.is_true(found_pids[20000], "Expected pkill for buffer PID 20000") + end) + + it("should not duplicate PIDs already tracked", function() + -- Track a PID directly + terminal.track_terminal_pid(42) + + -- Mock session manager to return the same job_id + package.loaded["claudecode.session"] = { + list_sessions = function() + return { + { id = "session_1", terminal_jobid = 42 }, -- Same as tracked + } + end, + } + + -- Mock empty buffer list + mock_vim.api.nvim_list_bufs = function() + return {} + end + + terminal.cleanup_all() + + -- Should only kill one PID (not duplicated) + assert.are.equal(1, #jobstop_calls) + assert.are.same({ 42 }, jobstop_calls) + end) + + it("should handle session manager load failure gracefully", function() + -- Make session manager require fail + package.loaded["claudecode.session"] = nil + package.preload["claudecode.session"] = function() + error("Module not found") + end + + -- Track a PID to verify cleanup still works + terminal.track_terminal_pid(42) + + -- Should not error + assert.has_no.errors(function() + terminal.cleanup_all() + end) + + -- Should still kill tracked PID + assert.are.equal(1, #jobstop_calls) + + -- Cleanup preload + package.preload["claudecode.session"] = nil + end) + + it("should handle buffer iteration failure gracefully", function() + -- Make nvim_list_bufs fail + mock_vim.api.nvim_list_bufs = function() + error("API error") + end + + -- Track a PID to verify cleanup still works + terminal.track_terminal_pid(42) + + -- Should not error + assert.has_no.errors(function() + terminal.cleanup_all() + end) + + -- Should still kill tracked PID + assert.are.equal(1, #jobstop_calls) + end) + end) +end) diff --git a/tests/verify_cleanup.lua b/tests/verify_cleanup.lua new file mode 100644 index 00000000..9dc3be13 --- /dev/null +++ b/tests/verify_cleanup.lua @@ -0,0 +1,75 @@ +-- Quick verification that processes are created and terminated +local function process_exists(pid) + local result = os.execute("kill -0 " .. pid .. " 2>/dev/null") + return result == true or result == 0 +end + +print("=== VERIFICATION TEST ===") + +-- 1. Spawn 3 processes +print("\n1. Spawning 3 processes...") +local jobs = {} +local pids = {} +for i = 1, 3 do + local job_id = vim.fn.jobstart({ "sleep", "300" }, { detach = false }) + local pid = vim.fn.jobpid(job_id) + jobs[i] = job_id + pids[i] = pid + print(" Process " .. i .. ": job_id=" .. job_id .. ", pid=" .. pid) +end + +-- 2. Verify they're running +print("\n2. Checking processes are running...") +for i, pid in ipairs(pids) do + local exists = process_exists(pid) + print(" PID " .. pid .. ": " .. (exists and "RUNNING ✓" or "NOT FOUND ✗")) +end + +-- 3. Show in ps +print("\n3. Listing sleep processes (ps):") +os.execute('ps aux | grep "sleep 300" | grep -v grep') + +-- 4. Track them +print("\n4. Tracking PIDs in terminal module...") +_G._claudecode_tracked_pids = {} +package.loaded["claudecode.terminal"] = nil +local terminal = require("claudecode.terminal") +for _, job_id in ipairs(jobs) do + terminal.track_terminal_pid(job_id) +end +print(" Tracked " .. #jobs .. " jobs") + +-- 5. Call cleanup_all +print("\n5. Calling cleanup_all()...") +terminal.cleanup_all() +print(" Done") + +-- 6. Wait a moment +vim.wait(500, function() + return false +end) + +-- 7. Verify they're dead +print("\n6. Checking processes are DEAD...") +local all_dead = true +for i, pid in ipairs(pids) do + local exists = process_exists(pid) + print(" PID " .. pid .. ": " .. (exists and "STILL RUNNING ✗" or "DEAD ✓")) + if exists then + all_dead = false + end +end + +-- 8. Show in ps again +print("\n7. Listing sleep processes (ps) - should be empty:") +os.execute('ps aux | grep "sleep 300" | grep -v grep') + +print("\n========================================") +if all_dead then + print("SUCCESS: All processes terminated!") +else + print("FAILURE: Some processes survived!") +end +print("========================================") + +vim.cmd("qa!")