From 8f8373b81131ed8ed0d8d38cc4e4b1db5bcb4b5b Mon Sep 17 00:00:00 2001 From: Snir Turgeman Date: Wed, 17 Dec 2025 18:06:27 +0200 Subject: [PATCH 1/8] feat: add multi-session terminal support Add ability to run multiple concurrent Claude Code terminal sessions with session management, smart ESC handling, and session-aware selection tracking. New commands: - ClaudeCodeNew: Create a new terminal session - ClaudeCodeSessions: Show session picker (supports fzf-lua) - ClaudeCodeSwitch: Switch to session by number - ClaudeCodeCloseSession: Close session by number or active session New features: - Smart ESC handling: double-tap ESC to exit terminal mode, single ESC sends to terminal (configurable via esc_timeout) - Session-aware selection tracking and message routing - OSC title handler for capturing terminal title changes - Configurable terminal keymaps (terminal.keymaps.exit_terminal) New modules: - lua/claudecode/session.lua: Session lifecycle management - lua/claudecode/terminal/osc_handler.lua: Terminal title detection --- lua/claudecode/config.lua | 12 + lua/claudecode/init.lua | 151 ++++++++ lua/claudecode/selection.lua | 41 +++ lua/claudecode/server/init.lua | 73 ++++ lua/claudecode/session.lua | 351 ++++++++++++++++++ lua/claudecode/terminal.lua | 376 ++++++++++++++++++- lua/claudecode/terminal/native.lua | 413 ++++++++++++++++++++- lua/claudecode/terminal/osc_handler.lua | 239 ++++++++++++ lua/claudecode/terminal/snacks.lua | 336 ++++++++++++++++- tests/unit/session_spec.lua | 440 +++++++++++++++++++++++ tests/unit/terminal/osc_handler_spec.lua | 171 +++++++++ 11 files changed, 2581 insertions(+), 22 deletions(-) create mode 100644 lua/claudecode/session.lua create mode 100644 lua/claudecode/terminal/osc_handler.lua create mode 100644 tests/unit/session_spec.lua create mode 100644 tests/unit/terminal/osc_handler_spec.lua diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 9e9d0e5a..a0127020 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -79,6 +79,18 @@ 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 + 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..a1ddeaa9 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -1020,6 +1020,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 +1142,95 @@ 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 (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 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, + }, + }) + 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..87f5001f 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 = { @@ -236,6 +238,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 +547,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 +568,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..3eb702ac --- /dev/null +++ b/lua/claudecode/session.lua @@ -0,0 +1,351 @@ +---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 .. ")") + + 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) + + 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..c923a5d3 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -1,10 +1,13 @@ ---- 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") ---@type ClaudeCodeTerminalConfig local defaults = { @@ -23,10 +26,125 @@ 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, } 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 +388,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 @@ -338,6 +458,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 +470,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 +621,42 @@ 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 else if k ~= "terminal_cmd" then vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN) @@ -500,7 +675,27 @@ 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 end ---Closes the managed Claude terminal if it's open and valid. @@ -515,7 +710,34 @@ 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 + + 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 end ---Smart focus toggle: switches to terminal if not focused, hides if currently focused. @@ -525,7 +747,34 @@ 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 + + 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 end ---Toggle open terminal without focus if not already visible, otherwise do nothing. @@ -569,4 +818,123 @@ 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) + + 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) + else + -- Fallback: use regular open (single terminal mode) + provider.open(cmd_string, claude_env_table, effective_config) + 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() + + if provider.close_session then + provider.close_session(session_id) + else + -- Fallback: use regular close + provider.close() + end + + session_manager.destroy_session(session_id) +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 + return M diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 7cd24dd5..d110eb15 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -1,16 +1,32 @@ ---Native Neovim terminal provider for Claude Code. +---Supports multiple terminal sessions. ---@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 winid number|nil +---@field jobid number|nil + +---@type table Map of session_id -> terminal state +local terminals = {} + +-- Forward declaration for show_hidden_session_terminal +local show_hidden_session_terminal + ---@type ClaudeCodeTerminalConfig local config = require("claudecode.terminal").defaults @@ -134,6 +150,10 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) vim.bo[bufnr].bufhidden = "hide" -- buftype=terminal is set by termopen + -- Set up terminal keymaps (smart ESC handling) + local terminal_module = require("claudecode.terminal") + terminal_module.setup_terminal_keymaps(bufnr, config) + if focus then -- Focus the terminal: switch to terminal window and enter insert mode vim.api.nvim_set_current_win(winid) @@ -144,7 +164,8 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) 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) + 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 return true @@ -435,5 +456,395 @@ 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 + +---Helper to find window displaying a session's terminal +---@param session_id string +---@return number|nil winid +local function find_session_window(session_id) + local state = terminals[session_id] + if not state or not state.bufnr then + return nil + end + + local windows = vim.api.nvim_list_wins() + for _, win in ipairs(windows) do + if vim.api.nvim_win_get_buf(win) == state.bufnr then + state.winid = win + return win + end + end + return nil +end + +---Hide all visible session terminals +---@param except_session_id string|nil Optional session ID to exclude from hiding +local function hide_all_session_terminals(except_session_id) + for sid, state in pairs(terminals) do + if sid ~= except_session_id and state and state.bufnr and vim.api.nvim_buf_is_valid(state.bufnr) then + -- Find and close the window if it's visible + local win = find_session_window(sid) + if win and vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, false) + state.winid = nil + end + end + end + + -- Also hide the legacy terminal if it's not one of the session terminals + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + local is_session_terminal = false + for _, state in pairs(terminals) do + if state.bufnr == bufnr then + is_session_terminal = true + break + end + end + + if not is_session_terminal and winid and vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_close(winid, false) + winid = nil + end + end +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) + focus = utils.normalize_focus(focus) + + -- Check if this session already has a valid terminal + if is_session_valid(session_id) then + -- Hide other session terminals first + hide_all_session_terminals(session_id) + + local win = find_session_window(session_id) + + if not win then + -- Terminal is hidden, show it + show_hidden_session_terminal(session_id, effective_config, focus) + elseif focus then + vim.api.nvim_set_current_win(win) + vim.cmd("startinsert") + end + return + end + + -- Hide all other session terminals before creating new one + hide_all_session_terminals(nil) + + -- Create new terminal for this session + 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) + + local term_cmd_arg + if cmd_string:find(" ", 1, true) then + term_cmd_arg = vim.split(cmd_string, " ", { plain = true, trimempty = false }) + else + term_cmd_arg = { cmd_string } + end + + local new_jobid = vim.fn.termopen(term_cmd_arg, { + env = env_table, + cwd = effective_config.cwd, + on_exit = function(job_id, _, _) + vim.schedule(function() + 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_winid = state.winid + local current_bufnr = state.bufnr + + -- Cleanup OSC handler before clearing state + if current_bufnr then + osc_handler.cleanup_buffer_handler(current_bufnr) + end + + -- Clear session state + terminals[session_id] = nil + + if not effective_config.auto_close then + return + end + + if current_winid and vim.api.nvim_win_is_valid(current_winid) then + if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then + if vim.api.nvim_win_get_buf(current_winid) == current_bufnr then + vim.api.nvim_win_close(current_winid, true) + end + else + vim.api.nvim_win_close(current_winid, true) + end + end + end + end) + end, + }) + + if not new_jobid or new_jobid == 0 then + vim.notify("Failed to open native terminal for session: " .. session_id, vim.log.levels.ERROR) + vim.api.nvim_win_close(new_winid, true) + vim.api.nvim_set_current_win(original_win) + return + end + + local new_bufnr = vim.api.nvim_get_current_buf() + vim.bo[new_bufnr].bufhidden = "hide" + + -- Set up terminal keymaps (smart ESC handling) + local terminal_module = require("claudecode.terminal") + terminal_module.setup_terminal_keymaps(new_bufnr, config) + + -- Store session state + terminals[session_id] = { + bufnr = new_bufnr, + winid = new_winid, + jobid = new_jobid, + } + + -- Also update legacy state for backward compatibility + bufnr = new_bufnr + winid = new_winid + jobid = new_jobid + + -- Update session manager with terminal info + terminal_module.update_session_terminal_info(session_id, { + bufnr = new_bufnr, + winid = new_winid, + jobid = new_jobid, + }) + + -- Setup OSC title handler to capture terminal title changes + osc_handler.setup_buffer_handler(new_bufnr, function(title) + if title and title ~= "" then + session_manager.update_session_name(session_id, title) + end + end) + + if focus then + vim.api.nvim_set_current_win(new_winid) + vim.cmd("startinsert") + else + vim.api.nvim_set_current_win(original_win) + 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 + +---Show a hidden session terminal +---@param session_id string +---@param effective_config table +---@param focus boolean? +local function show_hidden_session_terminal_impl(session_id, effective_config, focus) + 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 + + -- Check if already visible + local existing_win = find_session_window(session_id) + if existing_win then + if focus then + vim.api.nvim_set_current_win(existing_win) + vim.cmd("startinsert") + end + return true + end + + local original_win = vim.api.nvim_get_current_win() + + -- Create a new window for the existing buffer + local width = math.floor(vim.o.columns * effective_config.split_width_percentage) + local full_height = vim.o.lines + 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) + + -- Set the existing buffer in the new window + vim.api.nvim_win_set_buf(new_winid, state.bufnr) + state.winid = new_winid + + if focus then + vim.api.nvim_set_current_win(new_winid) + vim.cmd("startinsert") + else + vim.api.nvim_set_current_win(original_win) + end + + logger.debug("terminal", "Showed hidden terminal for session: " .. session_id) + return true +end + +-- Assign the implementation to forward declaration +show_hidden_session_terminal = show_hidden_session_terminal_impl + +---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 + + if state.winid and vim.api.nvim_win_is_valid(state.winid) then + vim.api.nvim_win_close(state.winid, true) + end + + terminals[session_id] = nil + + -- If this was the legacy terminal, clear it too + if bufnr == state.bufnr then + cleanup_state() + end +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) + -- 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 from session_manager + 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 + -- Legacy terminal matches this session, register it now + 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 + + -- Hide other session terminals first + hide_all_session_terminals(session_id) + + local win = find_session_window(session_id) + if not win then + -- Terminal is hidden, show it + if effective_config then + show_hidden_session_terminal(session_id, effective_config, true) + end + return + end + + vim.api.nvim_set_current_win(win) + vim.cmd("startinsert") +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 +---This is called when a terminal was created via simple_toggle/focus_toggle +---and we need to associate it with a session for multi-session support. +---@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 + -- Already registered to a different session, skip + 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, + winid = winid, + 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..66e8a360 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -1,12 +1,21 @@ ---Snacks.nvim terminal provider for Claude Code. +---Supports multiple terminal sessions. ---@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 = {} + --- @return boolean local function is_available() return snacks_available and Snacks and Snacks.terminal ~= nil @@ -15,7 +24,8 @@ 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") -- Handle command completion/exit - only if auto_close is enabled @@ -26,7 +36,11 @@ local function setup_terminal_events(term_instance, config) end -- Clean up - terminal = nil + if session_id then + terminals[session_id] = nil + else + terminal = nil + end vim.schedule(function() term_instance:close({ buf = true }) vim.cmd.checktime() @@ -36,8 +50,18 @@ 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 + else + terminal = nil + end end, { buf = true }) end @@ -48,6 +72,34 @@ end ---@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) + + -- 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 + -- When smart ESC is enabled, we set up our own keymap after terminal creation + 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 + return { env = env_table, cwd = config.cwd, @@ -59,19 +111,7 @@ local function build_opts(config, env_table, focus) 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", - }, - }, + keys = keys, } --[[@as snacks.win.Config]], config.snacks_win_opts or {}), } --[[@as snacks.terminal.Opts]] end @@ -132,6 +172,12 @@ function M.open(cmd_string, env_table, config, focus) if term_instance and term_instance:buf_valid() then setup_terminal_events(term_instance, config) terminal = term_instance + + -- Set up smart ESC handling if enabled + if config.esc_timeout and config.esc_timeout > 0 and term_instance.buf then + local terminal_module = require("claudecode.terminal") + terminal_module.setup_terminal_keymaps(term_instance.buf, config) + end else terminal = nil local logger = require("claudecode.logger") @@ -272,5 +318,261 @@ function M._get_terminal_for_test() return terminal end +-- ============================================================================ +-- Multi-session support functions +-- ============================================================================ + +---Hide all visible session terminals +---@param except_session_id string|nil Optional session ID to exclude from hiding +local function hide_all_session_terminals(except_session_id) + for sid, term_instance in pairs(terminals) do + if sid ~= except_session_id and term_instance and term_instance:buf_valid() then + -- If terminal is visible, hide it + if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then + term_instance:toggle() + end + end + end + + -- Also hide the legacy terminal if it's different + if terminal and terminal:buf_valid() then + -- Check if legacy terminal is one of the session terminals + local is_session_terminal = false + for _, term_instance in pairs(terminals) do + if term_instance == terminal then + is_session_terminal = true + break + end + end + + if not is_session_terminal and terminal.win and vim.api.nvim_win_is_valid(terminal.win) then + terminal:toggle() + end + end +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 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") + 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 + -- Hide other session terminals first + hide_all_session_terminals(session_id) + + -- Terminal exists, show/focus it + if not existing_term.win or not vim.api.nvim_win_is_valid(existing_term.win) then + existing_term:toggle() + end + if focus then + existing_term:focus() + local term_buf_id = existing_term.buf + if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then + if existing_term.win and vim.api.nvim_win_is_valid(existing_term.win) then + vim.api.nvim_win_call(existing_term.win, function() + vim.cmd("startinsert") + end) + end + end + end + return + end + + -- Hide all other session terminals before creating new one + hide_all_session_terminals(nil) + + -- Create new terminal for this session + local opts = build_opts(config, env_table, focus) + local term_instance = Snacks.terminal.open(cmd_string, opts) + + if term_instance and term_instance:buf_valid() then + setup_terminal_events(term_instance, config, session_id) + terminals[session_id] = term_instance + + -- Also set as legacy terminal for backward compatibility + terminal = term_instance + + -- 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 = term_instance.win, + }) + + -- 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 + + -- Setup OSC title handler to capture terminal title changes + 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 + + 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 + term_instance:close({ buf = true }) + terminals[session_id] = nil + + -- If this was the legacy terminal, clear it too + if terminal == term_instance then + terminal = nil + end + end +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 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 + -- Check if legacy terminal matches the session's bufnr from session_manager + 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 + -- Legacy terminal matches this session, register it now + 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 + + -- Hide other session terminals first + hide_all_session_terminals(session_id) + + -- If terminal is hidden, show it + if not term_instance.win or not vim.api.nvim_win_is_valid(term_instance.win) then + term_instance:toggle() + end + + -- Focus the terminal + term_instance:focus() + local term_buf_id = term_instance.buf + if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then + if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then + vim.api.nvim_win_call(term_instance.win, function() + vim.cmd("startinsert") + end) + end + end +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 +---This is called when a terminal was created via simple_toggle/focus_toggle +---and we need to associate it with a session for multi-session support. +---@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 no bufnr provided, use the legacy terminal + 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 this terminal is 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 + -- Already registered to a different session, skip + 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/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) From 9dee2c94e2d8efc438527a9e447838731b68736c Mon Sep 17 00:00:00 2001 From: Snir Turgeman Date: Thu, 18 Dec 2025 17:17:00 +0200 Subject: [PATCH 2/8] fix: cursor position when switching terminal sessions Add jobresize() calls to notify the terminal job of window dimensions when switching between sessions. This fixes the cursor appearing in the wrong position and line shifting after session switch. The terminal job needs to know its window dimensions to correctly calculate cursor position and line wrapping. Without this, the terminal renders based on stale window state from the previous session. --- lua/claudecode/init.lua | 72 +++++++++++++++++++++++++++++- lua/claudecode/terminal/native.lua | 17 +++++++ lua/claudecode/terminal/snacks.lua | 8 ++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index a1ddeaa9..9ab03baa 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -1197,11 +1197,64 @@ function M.show_session_picker() end end ----Try to use an enhanced picker (fzf-lua) +---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 + local picker_items = {} + for _, item in ipairs(items) do + table.insert(picker_items, { + text = item.display, + item = item, + }) + end + + Snacks.picker.pick({ + source = "claude_sessions", + items = picker_items, + 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) + picker:close() + 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 @@ -1223,6 +1276,23 @@ function M._try_picker(items, on_select) 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) + end + end + end, + -- Close picker after action since session list changed + exec_silent = true, + }, + }, + fzf_opts = { + ["--header"] = "Enter: switch | Ctrl-X: close session", }, }) return true diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index d110eb15..85caa592 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -712,6 +712,12 @@ local function show_hidden_session_terminal_impl(session_id, effective_config, f vim.api.nvim_win_set_buf(new_winid, state.bufnr) state.winid = new_winid + -- Notify terminal of window dimensions to fix cursor position after session switch + local chan = vim.bo[state.bufnr].channel + if chan and chan > 0 then + pcall(vim.fn.jobresize, chan, width, full_height) + end + if focus then vim.api.nvim_set_current_win(new_winid) vim.cmd("startinsert") @@ -777,6 +783,17 @@ function M.focus_session(session_id, effective_config) return end + -- Notify terminal of window dimensions to fix cursor position after session switch + local state = terminals[session_id] + if state and state.bufnr and vim.api.nvim_buf_is_valid(state.bufnr) then + local chan = vim.bo[state.bufnr].channel + if chan and chan > 0 then + local width = vim.api.nvim_win_get_width(win) + local height = vim.api.nvim_win_get_height(win) + pcall(vim.fn.jobresize, chan, width, height) + end + end + vim.api.nvim_set_current_win(win) vim.cmd("startinsert") end diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 66e8a360..7e20bd39 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -498,6 +498,14 @@ function M.focus_session(session_id, config) local term_buf_id = term_instance.buf if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then + -- Notify terminal of window dimensions to fix cursor position after session switch + local chan = vim.bo[term_buf_id].channel + if chan and chan > 0 then + local width = vim.api.nvim_win_get_width(term_instance.win) + local height = vim.api.nvim_win_get_height(term_instance.win) + pcall(vim.fn.jobresize, chan, width, height) + end + vim.api.nvim_win_call(term_instance.win, function() vim.cmd("startinsert") end) From c0a7705d8ed6b535f891ae6260f0a294b64d37e1 Mon Sep 17 00:00:00 2001 From: Snir Turgeman Date: Wed, 24 Dec 2025 12:03:01 +0200 Subject: [PATCH 3/8] feat: add tabbar UI with mouse support for terminal sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add clickable tabbar for session switching (floating and winbar modes) - Support left-click to switch sessions, middle-click to close - Add close button (✕) on each tab with same background as tab - Add new session button (+) for creating new sessions - Add scroll wheel support to cycle sessions in floating tabbar - Add mouse selection tracking (LeftRelease/LeftDrag) for better selection capture - Fix intentional close handling to suppress exit error on X click - Add config validation for terminal.tabs options --- lua/claudecode/config.lua | 35 ++ lua/claudecode/init.lua | 48 +- lua/claudecode/selection.lua | 44 ++ lua/claudecode/session.lua | 12 + lua/claudecode/terminal.lua | 196 +++++++- lua/claudecode/terminal/native.lua | 5 +- lua/claudecode/terminal/snacks.lua | 120 ++++- lua/claudecode/terminal/tabbar.lua | 709 +++++++++++++++++++++++++++++ 8 files changed, 1147 insertions(+), 22 deletions(-) create mode 100644 lua/claudecode/terminal/tabbar.lua diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index a0127020..5030a7da 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -91,6 +91,41 @@ function M.validate(config) 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 + 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 9ab03baa..623204d5 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -1205,17 +1205,35 @@ 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 - local picker_items = {} - for _, item in ipairs(items) do - table.insert(picker_items, { - text = item.display, - item = item, - }) + -- 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", - items = picker_items, + finder = session_finder, format = function(item) return { { item.text } } end, @@ -1234,7 +1252,13 @@ function M._try_picker(items, on_select) 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) - picker:close() + -- 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, }, @@ -1284,10 +1308,16 @@ function M._try_picker(items, on_select) 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, - -- Close picker after action since session list changed exec_silent = true, }, }, diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index 87f5001f..0f53f29a 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -16,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. @@ -31,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. @@ -81,6 +84,47 @@ function M._create_autocommands() }) 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. ---@local function M._clear_autocommands() diff --git a/lua/claudecode/session.lua b/lua/claudecode/session.lua index 3eb702ac..ffbfbe1e 100644 --- a/lua/claudecode/session.lua +++ b/lua/claudecode/session.lua @@ -62,6 +62,12 @@ function M.create_session(opts) 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 @@ -93,6 +99,12 @@ function M.destroy_session(session_id) 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 diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index c923a5d3..f0d1a628 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -33,6 +33,22 @@ local defaults = { -- 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, + -- 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 @@ -401,10 +417,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 @@ -657,6 +711,65 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) 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) @@ -666,6 +779,14 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) -- 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. @@ -696,10 +817,20 @@ function M.open(opts_override, cmd_args) 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() get_provider().close() end @@ -713,6 +844,7 @@ function M.simple_toggle(opts_override, cmd_args) -- 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) @@ -738,6 +870,23 @@ function M.simple_toggle(opts_override, cmd_args) 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. @@ -750,6 +899,7 @@ function M.focus_toggle(opts_override, cmd_args) -- 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) @@ -775,6 +925,23 @@ function M.focus_toggle(opts_override, cmd_args) 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. @@ -854,6 +1021,9 @@ function M.close_session(session_id) local provider = get_provider() + -- Detach tabbar before closing the terminal window + detach_tabbar() + if provider.close_session then provider.close_session(session_id) else @@ -862,6 +1032,30 @@ function M.close_session(session_id) end session_manager.destroy_session(session_id) + + -- If there are remaining sessions, switch to the new active session + local new_active_id = session_manager.get_active_session_id() + if new_active_id then + local effective_config = build_config(nil) + if provider.focus_session then + provider.focus_session(new_active_id, effective_config) + end + + -- Re-attach tabbar to the new session's terminal + 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 end ---Switches to a specific session. diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 85caa592..774ecb74 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -713,9 +713,12 @@ local function show_hidden_session_terminal_impl(session_id, effective_config, f state.winid = new_winid -- Notify terminal of window dimensions to fix cursor position after session switch + -- Use actual window dimensions, not calculated ones (vim.o.lines includes statusline, cmdline, etc.) local chan = vim.bo[state.bufnr].channel if chan and chan > 0 then - pcall(vim.fn.jobresize, chan, width, full_height) + local actual_width = vim.api.nvim_win_get_width(new_winid) + local actual_height = vim.api.nvim_win_get_height(new_winid) + pcall(vim.fn.jobresize, chan, actual_width, actual_height) end if focus then diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 7e20bd39..23cdee21 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -16,6 +16,10 @@ local terminal = nil ---@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 @@ -31,13 +35,16 @@ local function setup_terminal_events(term_instance, config, session_id) -- 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 + -- Only show error if this wasn't an intentional close + local is_intentional_close = session_id and closing_sessions[session_id] + 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 if session_id then terminals[session_id] = nil + closing_sessions[session_id] = nil else terminal = nil end @@ -65,12 +72,42 @@ local function setup_terminal_events(term_instance, config, session_id) end, { buf = true }) 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 with focus control ---@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) +---@param session_id string|nil Optional session ID for title ---@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) +local function build_opts(config, env_table, focus, session_id) focus = utils.normalize_focus(focus) -- Build keys table with optional exit_terminal keymap @@ -100,19 +137,32 @@ local function build_opts(config, env_table, focus) } 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, preserving wo options for winbar support + 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, + -- Don't clear winbar - we set it dynamically for session tabs + wo = {}, + } --[[@as snacks.win.Config]], config.snacks_win_opts or {}) + return { env = env_table, cwd = config.cwd, start_insert = focus, auto_insert = focus, auto_close = false, - win = vim.tbl_deep_extend("force", { - position = config.split_side, - width = config.split_width_percentage, - height = 0, - relative = "editor", - keys = keys, - } --[[@as snacks.win.Config]], config.snacks_win_opts or {}), + win = win_opts, } --[[@as snacks.terminal.Opts]] end @@ -174,10 +224,33 @@ function M.open(cmd_string, env_table, config, focus) terminal = term_instance -- 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 - local terminal_module = require("claudecode.terminal") terminal_module.setup_terminal_keymaps(term_instance.buf, config) end + + -- Ensure a session exists before attaching tabbar (session is needed for tabbar content) + local session_id = session_manager.ensure_session() + session_manager.update_terminal_info(session_id, { + bufnr = term_instance.buf, + winid = term_instance.win, + }) + + -- Attach tabbar directly with known window ID and snacks terminal instance + -- Use vim.schedule to ensure snacks has finished its window setup + if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then + local win_id = term_instance.win + local buf_id = term_instance.buf + local term_ref = term_instance + vim.schedule(function() + if vim.api.nvim_win_is_valid(win_id) then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(win_id, buf_id, term_ref) + end + end + end) + end else terminal = nil local logger = require("claudecode.logger") @@ -394,7 +467,7 @@ function M.open_session(session_id, cmd_string, env_table, config, focus) hide_all_session_terminals(nil) -- Create new terminal for this session - local opts = build_opts(config, env_table, focus) + local opts = build_opts(config, env_table, focus, session_id) local term_instance = Snacks.terminal.open(cmd_string, opts) if term_instance and term_instance:buf_valid() then @@ -425,6 +498,21 @@ function M.open_session(session_id, cmd_string, env_table, config, focus) end) end + -- Attach tabbar with snacks terminal instance for floating window title + if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then + local win_id = term_instance.win + local buf_id = term_instance.buf + local term_ref = term_instance + vim.schedule(function() + if vim.api.nvim_win_is_valid(win_id) then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(win_id, buf_id, term_ref) + 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) @@ -440,6 +528,8 @@ function M.close_session(session_id) 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 term_instance:close({ buf = true }) terminals[session_id] = nil @@ -511,6 +601,14 @@ function M.focus_session(session_id, config) end) end end + + -- Update tabbar with the new terminal instance + if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(term_instance.win, term_instance.buf, term_instance) + end + end end ---Get the buffer number for a session's terminal 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 From ccde2615dd46df0104712ace432c433589c72636 Mon Sep 17 00:00:00 2001 From: Snir Turgeman Date: Sun, 28 Dec 2025 10:53:15 +0200 Subject: [PATCH 4/8] fix: keep terminal window open when session exits with other sessions available When a Claude terminal session exits (e.g., via Ctrl-C), the window now stays open and switches to display another available session instead of closing entirely. This provides a smoother multi-session experience. Changes: - Add session switching logic to TermClose handlers in both providers - Disconnect old terminal instance from window before buffer switch - Check session existence before destroying to prevent double-destruction - Add close_session_keep_window() for explicit session switching --- lua/claudecode/terminal/native.lua | 321 ++++++++++++++++++++++++++++- lua/claudecode/terminal/snacks.lua | 269 +++++++++++++++++++++++- 2 files changed, 582 insertions(+), 8 deletions(-) diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 774ecb74..3ed2f13f 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -91,6 +91,9 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) local new_winid = vim.api.nvim_get_current_win() vim.api.nvim_win_set_height(new_winid, full_height) + -- Prevent terminal window from being resized by Neovim's equalalways + vim.wo[new_winid].winfixwidth = true + vim.api.nvim_win_call(new_winid, function() vim.cmd("enew") end) @@ -111,24 +114,126 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) 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 + -- Find the window currently displaying this terminal buffer + local current_winid_for_job = nil + if current_bufnr_for_job and vim.api.nvim_buf_is_valid(current_bufnr_for_job) then + 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) == current_bufnr_for_job then + current_winid_for_job = win + break + end + end + end + if not current_winid_for_job then + current_winid_for_job = winid + end + + -- Clean up OSC handler before clearing state + if current_bufnr_for_job then + osc_handler.cleanup_buffer_handler(current_bufnr_for_job) + end + + -- Check session count BEFORE destroying + local session_count = session_manager.get_session_count() + + -- Find and destroy any session associated with this terminal + local session = session_manager.find_session_by_bufnr(current_bufnr_for_job) + if session then + logger.debug("terminal", "Destroying session for exited terminal: " .. session.id) + -- Only destroy if session still exists (may have been destroyed by another handler) + if session_manager.get_session(session.id) then + session_manager.destroy_session(session.id) + end + end + cleanup_state() -- Clear our managed state first if not effective_config.auto_close then return end + -- If there are other sessions, switch to one instead of closing window + 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] + + -- Fallback: check session manager for terminal buffer + 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_active_id) + if + session_data + and session_data.terminal_bufnr + and vim.api.nvim_buf_is_valid(session_data.terminal_bufnr) + then + new_state = { + bufnr = session_data.terminal_bufnr, + winid = nil, + jobid = session_data.terminal_jobid, + } + terminals[new_active_id] = new_state + logger.debug("terminal", "Recovered terminal from session manager for: " .. new_active_id) + end + end + + if new_state and new_state.bufnr and vim.api.nvim_buf_is_valid(new_state.bufnr) then + if current_winid_for_job and vim.api.nvim_win_is_valid(current_winid_for_job) then + -- Switch the window to show the new session's buffer + vim.api.nvim_win_set_buf(current_winid_for_job, new_state.bufnr) + new_state.winid = current_winid_for_job + + -- Update legacy state + bufnr = new_state.bufnr + winid = new_state.winid + jobid = new_state.jobid + + -- Notify terminal of window dimensions + local chan = vim.bo[new_state.bufnr].channel + if chan and chan > 0 then + local win_width = vim.api.nvim_win_get_width(current_winid_for_job) + local win_height = vim.api.nvim_win_get_height(current_winid_for_job) + pcall(vim.fn.jobresize, chan, win_width, win_height) + end + + vim.api.nvim_set_current_win(current_winid_for_job) + vim.cmd("startinsert") + + -- Re-attach tabbar + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(current_winid_for_job, new_state.bufnr) + end + + logger.debug("terminal", "Legacy terminal switched to session " .. new_active_id) + + -- Delete the old buffer + if current_bufnr_for_job and vim.api.nvim_buf_is_valid(current_bufnr_for_job) then + vim.api.nvim_buf_delete(current_bufnr_for_job, { force = true }) + end + return + else + -- No valid window, show session in new window + logger.debug("terminal", "No valid window, showing session " .. new_active_id) + show_hidden_session_terminal(new_active_id, effective_config, true) + if current_bufnr_for_job and vim.api.nvim_buf_is_valid(current_bufnr_for_job) then + vim.api.nvim_buf_delete(current_bufnr_for_job, { force = true }) + end + return + end + end + end + end + + -- No other sessions, close the window 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) 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 @@ -251,6 +356,9 @@ local function show_hidden_terminal(effective_config, focus) local new_winid = vim.api.nvim_get_current_win() vim.api.nvim_win_set_height(new_winid, full_height) + -- Prevent terminal window from being resized by Neovim's equalalways + vim.wo[new_winid].winfixwidth = true + -- Set the existing buffer in the new window vim.api.nvim_win_set_buf(new_winid, bufnr) winid = new_winid @@ -566,6 +674,9 @@ function M.open_session(session_id, cmd_string, env_table, effective_config, foc local new_winid = vim.api.nvim_get_current_win() vim.api.nvim_win_set_height(new_winid, full_height) + -- Prevent terminal window from being resized by Neovim's equalalways + vim.wo[new_winid].winfixwidth = true + vim.api.nvim_win_call(new_winid, function() vim.cmd("enew") end) @@ -586,21 +697,140 @@ function M.open_session(session_id, cmd_string, env_table, effective_config, foc if state and job_id == state.jobid then logger.debug("terminal", "Terminal process exited for session: " .. session_id) - local current_winid = state.winid local current_bufnr = state.bufnr + -- Find the window currently displaying this terminal buffer + -- (more reliable than stored winid which might be stale) + local current_winid = nil + if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then + 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) == current_bufnr then + current_winid = win + break + end + end + end + -- Fallback to stored winid if buffer not visible + if not current_winid then + current_winid = state.winid + end + -- Cleanup OSC handler before clearing state if current_bufnr then osc_handler.cleanup_buffer_handler(current_bufnr) end - -- Clear session state + -- Check if there are other sessions before destroying + local session_count = session_manager.get_session_count() + + -- Clear terminal state terminals[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 + if not effective_config.auto_close then return end + -- If there are other sessions, switch to the new active session instead of closing window + 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] + + -- Fallback 1: check if any other terminal in our table is valid + if not new_state or not new_state.bufnr or not vim.api.nvim_buf_is_valid(new_state.bufnr) then + for sid, term_state in pairs(terminals) do + if + sid ~= session_id + and term_state + and term_state.bufnr + and vim.api.nvim_buf_is_valid(term_state.bufnr) + then + new_state = term_state + terminals[new_active_id] = new_state + logger.debug("terminal", "Recovered terminal from table for: " .. new_active_id) + break + end + end + end + + -- Fallback 2: check session manager for terminal buffer 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_active_id) + if + session_data + and session_data.terminal_bufnr + and vim.api.nvim_buf_is_valid(session_data.terminal_bufnr) + then + -- Register this terminal in our table + new_state = { + bufnr = session_data.terminal_bufnr, + winid = nil, + jobid = session_data.terminal_jobid, + } + terminals[new_active_id] = new_state + logger.debug("terminal", "Recovered terminal from session manager for: " .. new_active_id) + end + end + + if new_state and new_state.bufnr and vim.api.nvim_buf_is_valid(new_state.bufnr) then + -- Check if we have a valid window to reuse + if current_winid and vim.api.nvim_win_is_valid(current_winid) then + -- Switch the window to show the new session's buffer + vim.api.nvim_win_set_buf(current_winid, new_state.bufnr) + new_state.winid = current_winid + + -- Notify terminal of window dimensions + local chan = vim.bo[new_state.bufnr].channel + if chan and chan > 0 then + local win_width = vim.api.nvim_win_get_width(current_winid) + local win_height = vim.api.nvim_win_get_height(current_winid) + pcall(vim.fn.jobresize, chan, win_width, win_height) + end + + -- Update legacy state + bufnr = new_state.bufnr + winid = new_state.winid + jobid = new_state.jobid + + -- Focus and enter insert mode + vim.api.nvim_set_current_win(current_winid) + vim.cmd("startinsert") + + -- Re-attach tabbar + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(current_winid, new_state.bufnr) + end + + logger.debug("terminal", "Switched to session " .. new_active_id .. " after exit") + + -- Delete the 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 + else + -- No valid window to reuse, show the other session in a new window + logger.debug("terminal", "No valid window, showing session " .. new_active_id .. " in new window") + show_hidden_session_terminal(new_active_id, effective_config, true) + + -- Delete the 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 + end + + -- No other sessions or couldn't switch, close the window if current_winid and vim.api.nvim_win_is_valid(current_winid) then if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then if vim.api.nvim_win_get_buf(current_winid) == current_bufnr then @@ -708,6 +938,9 @@ local function show_hidden_session_terminal_impl(session_id, effective_config, f local new_winid = vim.api.nvim_get_current_win() vim.api.nvim_win_set_height(new_winid, full_height) + -- Prevent terminal window from being resized by Neovim's equalalways + vim.wo[new_winid].winfixwidth = true + -- Set the existing buffer in the new window vim.api.nvim_win_set_buf(new_winid, state.bufnr) state.winid = new_winid @@ -747,6 +980,12 @@ function M.close_session(session_id) vim.api.nvim_win_close(state.winid, true) 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 @@ -755,6 +994,76 @@ function M.close_session(session_id) 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 old_state = terminals[old_session_id] + local new_state = terminals[new_session_id] + + if not old_state then + return + end + + -- Get the window from the old session (if visible) + local target_winid = old_state.winid + if not target_winid or not vim.api.nvim_win_is_valid(target_winid) then + -- Old session's window is not visible, just clean up and show new session + if 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 + if bufnr == old_state.bufnr then + cleanup_state() + end + + -- Show the new session + show_hidden_session_terminal(new_session_id, effective_config, true) + return + end + + -- Switch the window to show the new session's buffer + if new_state and new_state.bufnr and vim.api.nvim_buf_is_valid(new_state.bufnr) then + -- Set the new buffer in the existing window + vim.api.nvim_win_set_buf(target_winid, new_state.bufnr) + new_state.winid = target_winid + + -- Notify terminal of window dimensions to fix cursor position + local chan = vim.bo[new_state.bufnr].channel + if chan and chan > 0 then + local width = vim.api.nvim_win_get_width(target_winid) + local height = vim.api.nvim_win_get_height(target_winid) + pcall(vim.fn.jobresize, chan, width, height) + end + + -- Focus and enter insert mode + vim.api.nvim_set_current_win(target_winid) + vim.cmd("startinsert") + else + -- New session doesn't have a valid terminal, show it + show_hidden_session_terminal(new_session_id, effective_config, true) + end + + -- Now clean up the old session's buffer + if 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 + + -- Update legacy state to point to new session + if bufnr == old_state.bufnr and new_state then + bufnr = new_state.bufnr + winid = new_state.winid + jobid = new_state.jobid + end + + 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 diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 23cdee21..e7e0fae9 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -41,14 +41,181 @@ local function setup_terminal_events(term_instance, config, session_id) logger.error("terminal", "Claude exited with code " .. vim.v.event.status .. ".\nCheck for any errors.") end - -- Clean up + -- Check if there are other sessions before destroying + local session_count = session_manager.get_session_count() + local current_bufnr = term_instance.buf + + -- Find the window currently displaying this terminal buffer + -- (more reliable than stored win which might be stale) + local current_winid = nil + if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then + 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) == current_bufnr then + current_winid = win + break + end + end + end + -- Fallback to stored win if buffer not visible + if not current_winid then + current_winid = term_instance.win + end + + -- 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 - terminal = nil + -- 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) + -- Only destroy if session still exists (may have been destroyed by another handler) + if session_manager.get_session(session.id) then + session_manager.destroy_session(session.id) + end + end + end + -- Don't set terminal = nil yet, we might need it for fallback end + vim.schedule(function() + -- If there are other sessions, switch to the new active session instead of closing + 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 1: 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 2: 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 + + -- Fallback 3: check session manager for terminal buffer and find matching terminal + if not new_term or not new_term:buf_valid() then + local session_data = session_manager.get_session(new_active_id) + if + session_data + and session_data.terminal_bufnr + and vim.api.nvim_buf_is_valid(session_data.terminal_bufnr) + then + -- Search all terminals for one with this buffer + for _, term in pairs(terminals) do + if term and term:buf_valid() and term.buf == session_data.terminal_bufnr then + new_term = term + terminals[new_active_id] = new_term + logger.debug("terminal", "Recovered terminal by buffer for session: " .. new_active_id) + break + end + end + -- Also check global terminal + if + (not new_term or not new_term:buf_valid()) + and terminal + and terminal:buf_valid() + and terminal.buf == session_data.terminal_bufnr + then + new_term = terminal + terminals[new_active_id] = new_term + logger.debug("terminal", "Recovered global terminal by buffer for session: " .. new_active_id) + end + end + end + + if new_term and new_term:buf_valid() and new_term.buf then + -- Keep the window open and switch to the other session's buffer + if current_winid and vim.api.nvim_win_is_valid(current_winid) then + -- Disconnect old terminal instance from this window + -- (so it doesn't interfere when we delete its buffer) + term_instance.win = nil + + -- Switch the window to show the new session's buffer + vim.api.nvim_win_set_buf(current_winid, new_term.buf) + new_term.win = current_winid + + -- Notify terminal of window dimensions + local chan = vim.bo[new_term.buf].channel + if chan and chan > 0 then + local width = vim.api.nvim_win_get_width(current_winid) + local height = vim.api.nvim_win_get_height(current_winid) + pcall(vim.fn.jobresize, chan, width, height) + end + + -- Update legacy terminal reference + terminal = new_term + + -- Focus and enter insert mode + vim.api.nvim_set_current_win(current_winid) + if vim.api.nvim_buf_get_option(new_term.buf, "buftype") == "terminal" then + vim.cmd("startinsert") + end + + -- Re-attach tabbar + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(current_winid, new_term.buf, new_term) + end + + logger.debug("terminal", "Switched to session " .. new_active_id .. " in same window") + + -- Delete the old buffer after switching (buffer is no longer displayed) + 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 + else + -- No valid window, show the other session using snacks toggle + logger.debug("terminal", "No valid window, showing session " .. new_active_id) + terminal = new_term + new_term:toggle() + if new_term.win and vim.api.nvim_win_is_valid(new_term.win) then + new_term:focus() + if new_term.buf and vim.api.nvim_buf_get_option(new_term.buf, "buftype") == "terminal" then + vim.api.nvim_win_call(new_term.win, function() + vim.cmd("startinsert") + end) + end + end + vim.cmd.checktime() + return + end + end + end + end + + -- No other sessions or couldn't switch, close normally + -- Clear terminal reference if this was the legacy terminal + if terminal == term_instance then + terminal = nil + end term_instance:close({ buf = true }) vim.cmd.checktime() end) @@ -66,7 +233,22 @@ local function setup_terminal_events(term_instance, config, session_id) if session_id then terminals[session_id] = nil + -- Destroy the session in session manager to prevent zombie sessions (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 + logger.debug("terminal", "Destroying session for wiped terminal: " .. session.id) + -- Only destroy if session still exists (may have been destroyed by TermClose) + if session_manager.get_session(session.id) then + session_manager.destroy_session(session.id) + end + end + end terminal = nil end end, { buf = true }) @@ -540,6 +722,89 @@ function M.close_session(session_id) 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 old_term = terminals[old_session_id] + local new_term = terminals[new_session_id] + + if not old_term then + return + end + + -- Mark as intentional close + closing_sessions[old_session_id] = true + + -- Get the window from the old terminal + local target_winid = old_term.win + local had_visible_window = target_winid and vim.api.nvim_win_is_valid(target_winid) + + -- If new terminal exists, switch to it in the same window + if new_term and new_term:buf_valid() then + if had_visible_window and new_term.buf then + -- Set the new buffer in the existing window + vim.api.nvim_win_set_buf(target_winid, new_term.buf) + new_term.win = target_winid + + -- Notify terminal of window dimensions + local chan = vim.bo[new_term.buf].channel + if chan and chan > 0 then + local width = vim.api.nvim_win_get_width(target_winid) + local height = vim.api.nvim_win_get_height(target_winid) + pcall(vim.fn.jobresize, chan, width, height) + end + + -- Focus and enter insert mode + vim.api.nvim_set_current_win(target_winid) + if vim.api.nvim_buf_get_option(new_term.buf, "buftype") == "terminal" then + vim.cmd("startinsert") + end + + -- Update tabbar + local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") + if ok then + tabbar.attach(target_winid, new_term.buf, new_term) + end + elseif not had_visible_window then + -- Old window not visible, show new terminal + new_term:toggle() + if new_term.win and vim.api.nvim_win_is_valid(new_term.win) then + new_term:focus() + if new_term.buf and vim.api.nvim_buf_get_option(new_term.buf, "buftype") == "terminal" then + vim.api.nvim_win_call(new_term.win, function() + vim.cmd("startinsert") + end) + end + end + end + + -- Update legacy terminal reference + terminal = new_term + end + + -- Now close the old terminal's buffer (but window is already reused) + if old_term:buf_valid() then + -- Cleanup OSC handler + if old_term.buf then + osc_handler.cleanup_buffer_handler(old_term.buf) + end + -- Close just the buffer, not the window + vim.api.nvim_buf_delete(old_term.buf, { force = true }) + end + + terminals[old_session_id] = nil + closing_sessions[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 config ClaudeCodeTerminalConfig|nil Terminal configuration for showing hidden terminal From 9c74f95216c66cf02284f878a08d655ae9c5260a Mon Sep 17 00:00:00 2001 From: Snir Turgeman Date: Sun, 4 Jan 2026 12:17:23 +0200 Subject: [PATCH 5/8] fix: preserve terminal window size across session operations Add dedicated window_manager module that owns the single terminal window. This separates window lifecycle from buffer lifecycle, fixing issues where: - Creating new sessions would reset window to default size - Switching between tabs could cause window duplication - Closing tabs could leave windows in wrong positions Changes: - Add window_manager.lua: singleton that manages THE terminal window - Refactor snacks.lua: create buffers only, delegate window to manager - Refactor native.lua: simplified buffer-only management - Update terminal.lua: initialize window_manager, improve tab navigation - New tab now selects the created session - Closing tab selects previous tab (or next if first) --- ARCHITECTURE.md | 46 +- lua/claudecode/terminal.lua | 118 +- lua/claudecode/terminal/native.lua | 1156 ++++++-------------- lua/claudecode/terminal/snacks.lua | 607 ++++------ lua/claudecode/terminal/window_manager.lua | 216 ++++ 5 files changed, 907 insertions(+), 1236 deletions(-) create mode 100644 lua/claudecode/terminal/window_manager.lua 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/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index f0d1a628..f458c4cb 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -777,6 +777,13 @@ 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) @@ -831,6 +838,7 @@ 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 @@ -998,14 +1006,17 @@ function M.open_new_session(opts_override, cmd_args) 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) + 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) + provider.open(cmd_string, claude_env_table, effective_config, true) -- true = focus end return session_id @@ -1020,41 +1031,94 @@ function M.close_session(session_id) 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 - -- Detach tabbar before closing the terminal window - detach_tabbar() + 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 - if provider.close_session then - provider.close_session(session_id) - else - -- Fallback: use regular close - provider.close() - 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() - session_manager.destroy_session(session_id) + if provider.close_session then + provider.close_session(session_id) + else + provider.close() + end - -- If there are remaining sessions, switch to the new active session - local new_active_id = session_manager.get_active_session_id() - if new_active_id then - local effective_config = build_config(nil) - if provider.focus_session then - provider.focus_session(new_active_id, effective_config) + 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 - 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_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) + 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 diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 3ed2f13f..2279f87c 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -1,5 +1,6 @@ ---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 = {} @@ -11,92 +12,50 @@ 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 winid number|nil ---@field jobid number|nil ---@type table Map of session_id -> terminal state local terminals = {} --- Forward declaration for show_hidden_session_terminal -local show_hidden_session_terminal - ---@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 - 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 +---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") - if effective_config.split_side == "left" then - placement_modifier = "topleft " - else - placement_modifier = "botright " + -- 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 - 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) - - -- Prevent terminal window from being resized by Neovim's equalalways - vim.wo[new_winid].winfixwidth = true - - vim.api.nvim_win_call(new_winid, function() - vim.cmd("enew") - end) + vim.bo[new_bufnr].bufhidden = "hide" local term_cmd_arg if cmd_string:find(" ", 1, true) then @@ -105,346 +64,215 @@ 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_bufnr_for_job = bufnr - - -- Find the window currently displaying this terminal buffer - local current_winid_for_job = nil - if current_bufnr_for_job and vim.api.nvim_buf_is_valid(current_bufnr_for_job) then - 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) == current_bufnr_for_job then - current_winid_for_job = win - break + -- 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() + -- 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 - end - end - if not current_winid_for_job then - current_winid_for_job = winid - end - - -- Clean up OSC handler before clearing state - if current_bufnr_for_job then - osc_handler.cleanup_buffer_handler(current_bufnr_for_job) - end - - -- Check session count BEFORE destroying - local session_count = session_manager.get_session_count() - - -- Find and destroy any session associated with this terminal - local session = session_manager.find_session_by_bufnr(current_bufnr_for_job) - if session then - logger.debug("terminal", "Destroying session for exited terminal: " .. session.id) - -- Only destroy if session still exists (may have been destroyed by another handler) - if session_manager.get_session(session.id) then - session_manager.destroy_session(session.id) - end - end - cleanup_state() -- Clear our managed state first + local session_count = session_manager.get_session_count() + terminals[session_id] = nil - if not effective_config.auto_close then - return - end - - -- If there are other sessions, switch to one instead of closing window - 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] - - -- Fallback: check session manager for terminal buffer - 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_active_id) - if - session_data - and session_data.terminal_bufnr - and vim.api.nvim_buf_is_valid(session_data.terminal_bufnr) - then - new_state = { - bufnr = session_data.terminal_bufnr, - winid = nil, - jobid = session_data.terminal_jobid, - } - terminals[new_active_id] = new_state - logger.debug("terminal", "Recovered terminal from session manager for: " .. new_active_id) - end + if session_manager.get_session(session_id) then + session_manager.destroy_session(session_id) end - if new_state and new_state.bufnr and vim.api.nvim_buf_is_valid(new_state.bufnr) then - if current_winid_for_job and vim.api.nvim_win_is_valid(current_winid_for_job) then - -- Switch the window to show the new session's buffer - vim.api.nvim_win_set_buf(current_winid_for_job, new_state.bufnr) - new_state.winid = current_winid_for_job - - -- Update legacy state - bufnr = new_state.bufnr - winid = new_state.winid - jobid = new_state.jobid - - -- Notify terminal of window dimensions - local chan = vim.bo[new_state.bufnr].channel - if chan and chan > 0 then - local win_width = vim.api.nvim_win_get_width(current_winid_for_job) - local win_height = vim.api.nvim_win_get_height(current_winid_for_job) - pcall(vim.fn.jobresize, chan, win_width, win_height) - end - - vim.api.nvim_set_current_win(current_winid_for_job) - vim.cmd("startinsert") - - -- Re-attach tabbar - local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") - if ok then - tabbar.attach(current_winid_for_job, new_state.bufnr) - end - - logger.debug("terminal", "Legacy terminal switched to session " .. new_active_id) + if not effective_config.auto_close then + return + end - -- Delete the old buffer - if current_bufnr_for_job and vim.api.nvim_buf_is_valid(current_bufnr_for_job) then - vim.api.nvim_buf_delete(current_bufnr_for_job, { force = 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_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 - return - else - -- No valid window, show session in new window - logger.debug("terminal", "No valid window, showing session " .. new_active_id) - show_hidden_session_terminal(new_active_id, effective_config, true) - if current_bufnr_for_job and vim.api.nvim_buf_is_valid(current_bufnr_for_job) then - vim.api.nvim_buf_delete(current_bufnr_for_job, { force = true }) - end - return end end - end - end - -- No other sessions, close the window - 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 - 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) + -- 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 - else - vim.api.nvim_win_close(current_winid_for_job, true) end - end - end - end) - end, - }) + else + -- Legacy terminal exit handling + if job_id == jobid then + logger.debug("terminal", "Terminal process exited, cleaning up") - 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 + local current_bufnr = bufnr - winid = new_winid - bufnr = vim.api.nvim_get_current_buf() - vim.bo[bufnr].bufhidden = "hide" - -- buftype=terminal is set by termopen + if current_bufnr then + osc_handler.cleanup_buffer_handler(current_bufnr) + end - -- Set up terminal keymaps (smart ESC handling) - local terminal_module = require("claudecode.terminal") - terminal_module.setup_terminal_keymaps(bufnr, config) + 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 - 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 + cleanup_state() - 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 - return true -end - -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 not effective_config.auto_close then + return + end -local function focus_terminal() - if is_valid() then - vim.api.nvim_set_current_win(winid) - vim.cmd("startinsert") - end -end + -- 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 -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 + window_manager.close_window() + end + end + 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 + if not new_jobid or new_jobid == 0 then + vim.api.nvim_buf_delete(new_bufnr, { force = true }) + return nil, nil end - -- Buffer exists but no window displays it - winid = nil - return false + return new_bufnr, new_jobid end -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 - - logger.debug("terminal", "Terminal window hidden, process preserved") - end +---Setup the terminal module +---@param term_config ClaudeCodeTerminalConfig +function M.setup(term_config) + config = term_config 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 +--- @param cmd_string string +--- @param env_table table +--- @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) - -- Check if it's already visible - if is_terminal_visible() then - if focus then - focus_terminal() - end - return true + if is_valid() then + -- Terminal buffer exists, display it via window manager + window_manager.display_buffer(bufnr, focus) + return end - local original_win = vim.api.nvim_get_current_win() + -- Ensure a session exists + local session_id = session_manager.ensure_session() - -- 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 - - if effective_config.split_side == "left" then - placement_modifier = "topleft " - else - placement_modifier = "botright " + -- 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 - 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) + bufnr = new_bufnr + jobid = new_jobid - -- Prevent terminal window from being resized by Neovim's equalalways - vim.wo[new_winid].winfixwidth = true + -- Display buffer via window manager + window_manager.display_buffer(bufnr, focus) - -- Set the existing buffer in the new window - vim.api.nvim_win_set_buf(new_winid, bufnr) - winid = new_winid + -- Set up terminal keymaps + local terminal_module = require("claudecode.terminal") + terminal_module.setup_terminal_keymaps(bufnr, config) - if focus then - -- Focus the terminal: switch to terminal window and enter insert mode - vim.api.nvim_set_current_win(winid) - vim.cmd("startinsert") - else - -- Preserve user context: return to the window they were in before showing terminal - vim.api.nvim_set_current_win(original_win) - end + -- Update session info + session_manager.update_terminal_info(session_id, { + bufnr = bufnr, + winid = window_manager.get_window(), + jobid = jobid, + }) - logger.debug("terminal", "Showed hidden terminal in new window") - return true -end + -- Also register in terminals table + terminals[session_id] = { + bufnr = bufnr, + jobid = jobid, + } -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 + -- 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 - return nil, nil -end - ----Setup the terminal module ----@param term_config ClaudeCodeTerminalConfig -function M.setup(term_config) - config = term_config -end ---- @param cmd_string string ---- @param env_table table ---- @param effective_config table ---- @param focus boolean|nil -function M.open(cmd_string, env_table, effective_config, focus) - 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 - 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 @@ -452,38 +280,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 @@ -492,53 +302,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 @@ -579,56 +367,6 @@ local function is_session_valid(session_id) return true end ----Helper to find window displaying a session's terminal ----@param session_id string ----@return number|nil winid -local function find_session_window(session_id) - local state = terminals[session_id] - if not state or not state.bufnr then - return nil - end - - local windows = vim.api.nvim_list_wins() - for _, win in ipairs(windows) do - if vim.api.nvim_win_get_buf(win) == state.bufnr then - state.winid = win - return win - end - end - return nil -end - ----Hide all visible session terminals ----@param except_session_id string|nil Optional session ID to exclude from hiding -local function hide_all_session_terminals(except_session_id) - for sid, state in pairs(terminals) do - if sid ~= except_session_id and state and state.bufnr and vim.api.nvim_buf_is_valid(state.bufnr) then - -- Find and close the window if it's visible - local win = find_session_window(sid) - if win and vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, false) - state.winid = nil - end - end - end - - -- Also hide the legacy terminal if it's not one of the session terminals - if bufnr and vim.api.nvim_buf_is_valid(bufnr) then - local is_session_terminal = false - for _, state in pairs(terminals) do - if state.bufnr == bufnr then - is_session_terminal = true - break - end - end - - if not is_session_terminal and winid and vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_close(winid, false) - winid = nil - end - end -end - ---Open a terminal for a specific session ---@param session_id string The session ID ---@param cmd_string string The command to run @@ -636,260 +374,78 @@ end ---@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) - -- Check if this session already has a valid terminal - if is_session_valid(session_id) then - -- Hide other session terminals first - hide_all_session_terminals(session_id) + logger.debug("terminal", "open_session called for: " .. session_id) - local win = find_session_window(session_id) - - if not win then - -- Terminal is hidden, show it - show_hidden_session_terminal(session_id, effective_config, focus) - elseif focus then - vim.api.nvim_set_current_win(win) - vim.cmd("startinsert") + -- 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 - return - end - - -- Hide all other session terminals before creating new one - hide_all_session_terminals(nil) - - -- Create new terminal for this session - 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) - - -- Prevent terminal window from being resized by Neovim's equalalways - vim.wo[new_winid].winfixwidth = true - - vim.api.nvim_win_call(new_winid, function() - vim.cmd("enew") - end) - - local term_cmd_arg - if cmd_string:find(" ", 1, true) then - term_cmd_arg = vim.split(cmd_string, " ", { plain = true, trimempty = false }) - else - term_cmd_arg = { cmd_string } + logger.debug("terminal", "Displayed existing terminal for session: " .. session_id) + return end - local new_jobid = vim.fn.termopen(term_cmd_arg, { - env = env_table, - cwd = effective_config.cwd, - on_exit = function(job_id, _, _) - vim.schedule(function() - 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 - - -- Find the window currently displaying this terminal buffer - -- (more reliable than stored winid which might be stale) - local current_winid = nil - if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then - 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) == current_bufnr then - current_winid = win - break - end - end - end - -- Fallback to stored winid if buffer not visible - if not current_winid then - current_winid = state.winid - end - - -- Cleanup OSC handler before clearing state - if current_bufnr then - osc_handler.cleanup_buffer_handler(current_bufnr) - end - - -- Check if there are other sessions before destroying - local session_count = session_manager.get_session_count() - - -- Clear terminal state - terminals[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 - - if not effective_config.auto_close then - return - end - - -- If there are other sessions, switch to the new active session instead of closing window - 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] - - -- Fallback 1: check if any other terminal in our table is valid - if not new_state or not new_state.bufnr or not vim.api.nvim_buf_is_valid(new_state.bufnr) then - for sid, term_state in pairs(terminals) do - if - sid ~= session_id - and term_state - and term_state.bufnr - and vim.api.nvim_buf_is_valid(term_state.bufnr) - then - new_state = term_state - terminals[new_active_id] = new_state - logger.debug("terminal", "Recovered terminal from table for: " .. new_active_id) - break - end - end - end - - -- Fallback 2: check session manager for terminal buffer 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_active_id) - if - session_data - and session_data.terminal_bufnr - and vim.api.nvim_buf_is_valid(session_data.terminal_bufnr) - then - -- Register this terminal in our table - new_state = { - bufnr = session_data.terminal_bufnr, - winid = nil, - jobid = session_data.terminal_jobid, - } - terminals[new_active_id] = new_state - logger.debug("terminal", "Recovered terminal from session manager for: " .. new_active_id) - end - end - - if new_state and new_state.bufnr and vim.api.nvim_buf_is_valid(new_state.bufnr) then - -- Check if we have a valid window to reuse - if current_winid and vim.api.nvim_win_is_valid(current_winid) then - -- Switch the window to show the new session's buffer - vim.api.nvim_win_set_buf(current_winid, new_state.bufnr) - new_state.winid = current_winid - - -- Notify terminal of window dimensions - local chan = vim.bo[new_state.bufnr].channel - if chan and chan > 0 then - local win_width = vim.api.nvim_win_get_width(current_winid) - local win_height = vim.api.nvim_win_get_height(current_winid) - pcall(vim.fn.jobresize, chan, win_width, win_height) - end - - -- Update legacy state - bufnr = new_state.bufnr - winid = new_state.winid - jobid = new_state.jobid - - -- Focus and enter insert mode - vim.api.nvim_set_current_win(current_winid) - vim.cmd("startinsert") - - -- Re-attach tabbar - local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") - if ok then - tabbar.attach(current_winid, new_state.bufnr) - end - - logger.debug("terminal", "Switched to session " .. new_active_id .. " after exit") - - -- Delete the 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 - else - -- No valid window to reuse, show the other session in a new window - logger.debug("terminal", "No valid window, showing session " .. new_active_id .. " in new window") - show_hidden_session_terminal(new_active_id, effective_config, true) - - -- Delete the 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 - end - - -- No other sessions or couldn't switch, close the window - if current_winid and vim.api.nvim_win_is_valid(current_winid) then - if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then - if vim.api.nvim_win_get_buf(current_winid) == current_bufnr then - vim.api.nvim_win_close(current_winid, true) - end - else - vim.api.nvim_win_close(current_winid, true) - end - end - end - end) - end, - }) - - if not new_jobid or new_jobid == 0 then + -- 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) - vim.api.nvim_win_close(new_winid, true) - vim.api.nvim_set_current_win(original_win) return end - local new_bufnr = vim.api.nvim_get_current_buf() - vim.bo[new_bufnr].bufhidden = "hide" + -- Display buffer via window manager + window_manager.display_buffer(new_bufnr, focus) - -- Set up terminal keymaps (smart ESC handling) + -- 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, - winid = new_winid, jobid = new_jobid, } - -- Also update legacy state for backward compatibility + -- Update legacy state bufnr = new_bufnr - winid = new_winid jobid = new_jobid - -- Update session manager with terminal info + -- Update session manager terminal_module.update_session_terminal_info(session_id, { bufnr = new_bufnr, - winid = new_winid, + winid = window_manager.get_window(), jobid = new_jobid, }) - -- Setup OSC title handler to capture terminal title changes + -- 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) - if focus then - vim.api.nvim_set_current_win(new_winid) - vim.cmd("startinsert") - else - vim.api.nvim_set_current_win(original_win) + -- 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 @@ -901,73 +457,6 @@ function M.open_session(session_id, cmd_string, env_table, effective_config, foc logger.debug("terminal", "Opened terminal for session: " .. session_id) end ----Show a hidden session terminal ----@param session_id string ----@param effective_config table ----@param focus boolean? -local function show_hidden_session_terminal_impl(session_id, effective_config, focus) - 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 - - -- Check if already visible - local existing_win = find_session_window(session_id) - if existing_win then - if focus then - vim.api.nvim_set_current_win(existing_win) - vim.cmd("startinsert") - end - return true - end - - local original_win = vim.api.nvim_get_current_win() - - -- Create a new window for the existing buffer - local width = math.floor(vim.o.columns * effective_config.split_width_percentage) - local full_height = vim.o.lines - 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) - - -- Prevent terminal window from being resized by Neovim's equalalways - vim.wo[new_winid].winfixwidth = true - - -- Set the existing buffer in the new window - vim.api.nvim_win_set_buf(new_winid, state.bufnr) - state.winid = new_winid - - -- Notify terminal of window dimensions to fix cursor position after session switch - -- Use actual window dimensions, not calculated ones (vim.o.lines includes statusline, cmdline, etc.) - local chan = vim.bo[state.bufnr].channel - if chan and chan > 0 then - local actual_width = vim.api.nvim_win_get_width(new_winid) - local actual_height = vim.api.nvim_win_get_height(new_winid) - pcall(vim.fn.jobresize, chan, actual_width, actual_height) - end - - if focus then - vim.api.nvim_set_current_win(new_winid) - vim.cmd("startinsert") - else - vim.api.nvim_set_current_win(original_win) - end - - logger.debug("terminal", "Showed hidden terminal for session: " .. session_id) - return true -end - --- Assign the implementation to forward declaration -show_hidden_session_terminal = show_hidden_session_terminal_impl - ---Close a terminal for a specific session ---@param session_id string The session ID function M.close_session(session_id) @@ -976,10 +465,6 @@ function M.close_session(session_id) return end - if state.winid and vim.api.nvim_win_is_valid(state.winid) then - vim.api.nvim_win_close(state.winid, true) - 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) @@ -999,68 +484,78 @@ end ---@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] - if not old_state then - return + -- 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 - -- Get the window from the old session (if visible) - local target_winid = old_state.winid - if not target_winid or not vim.api.nvim_win_is_valid(target_winid) then - -- Old session's window is not visible, just clean up and show new session - if 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 }) + -- 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 - terminals[old_session_id] = nil - if bufnr == old_state.bufnr then - cleanup_state() + if not old_state then + logger.debug("terminal", "No terminal found for old session: " .. old_session_id) + return end - - -- Show the new session - show_hidden_session_terminal(new_session_id, effective_config, true) - return end - -- Switch the window to show the new session's buffer + -- 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 - -- Set the new buffer in the existing window - vim.api.nvim_win_set_buf(target_winid, new_state.bufnr) - new_state.winid = target_winid - - -- Notify terminal of window dimensions to fix cursor position - local chan = vim.bo[new_state.bufnr].channel - if chan and chan > 0 then - local width = vim.api.nvim_win_get_width(target_winid) - local height = vim.api.nvim_win_get_height(target_winid) - pcall(vim.fn.jobresize, chan, width, height) - end + window_manager.display_buffer(new_state.bufnr, true) - -- Focus and enter insert mode - vim.api.nvim_set_current_win(target_winid) - vim.cmd("startinsert") + -- 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 - -- New session doesn't have a valid terminal, show it - show_hidden_session_terminal(new_session_id, effective_config, true) + -- 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 - -- Now clean up the old session's buffer - if old_state.bufnr and vim.api.nvim_buf_is_valid(old_state.bufnr) then + -- 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 - -- Update legacy state to point to new session - if bufnr == old_state.bufnr and new_state then - bufnr = new_state.bufnr - winid = new_state.winid - jobid = new_state.jobid - end - logger.debug("terminal", "Closed session " .. old_session_id .. " and switched to " .. new_session_id) end @@ -1068,13 +563,14 @@ end ---@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 from session_manager + -- 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 - -- Legacy terminal matches this session, register it now logger.debug("terminal", "Registering legacy terminal for session: " .. session_id) M.register_terminal_for_session(session_id, bufnr) else @@ -1083,31 +579,29 @@ function M.focus_session(session_id, effective_config) end end - -- Hide other session terminals first - hide_all_session_terminals(session_id) - - local win = find_session_window(session_id) - if not win then - -- Terminal is hidden, show it - if effective_config then - show_hidden_session_terminal(session_id, effective_config, true) - 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 - -- Notify terminal of window dimensions to fix cursor position after session switch - local state = terminals[session_id] - if state and state.bufnr and vim.api.nvim_buf_is_valid(state.bufnr) then - local chan = vim.bo[state.bufnr].channel - if chan and chan > 0 then - local width = vim.api.nvim_win_get_width(win) - local height = vim.api.nvim_win_get_height(win) - pcall(vim.fn.jobresize, chan, width, height) + -- 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 - vim.api.nvim_set_current_win(win) - vim.cmd("startinsert") + logger.debug("terminal", "Focused session: " .. session_id) end ---Get the buffer number for a session's terminal @@ -1134,8 +628,6 @@ function M.get_active_session_ids() end ---Register an existing terminal (from legacy path) with a session ID ----This is called when a terminal was created via simple_toggle/focus_toggle ----and we need to associate it with a session for multi-session support. ---@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) @@ -1149,7 +641,6 @@ function M.register_terminal_for_session(session_id, term_bufnr) -- 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 - -- Already registered to a different session, skip logger.debug( "terminal", "Terminal already registered to session " .. sid .. ", not registering to " .. session_id @@ -1168,7 +659,6 @@ function M.register_terminal_for_session(session_id, term_bufnr) -- Register the legacy terminal with the session terminals[session_id] = { bufnr = term_bufnr, - winid = winid, jobid = jobid, } diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index e7e0fae9..5a03c0a3 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -1,5 +1,6 @@ ---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 = {} @@ -31,37 +32,37 @@ end ---@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() - -- Only show error if this wasn't an intentional close + -- 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 + -- 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 - -- Find the window currently displaying this terminal buffer - -- (more reliable than stored win which might be stale) - local current_winid = nil - if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then - 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) == current_bufnr then - current_winid = win - break - end - end - end - -- Fallback to stored win if buffer not visible - if not current_winid then - current_winid = term_instance.win - end - -- Track the exited session ID for cleanup local exited_session_id = session_id @@ -80,23 +81,21 @@ local function setup_terminal_events(term_instance, config, session_id) if session then exited_session_id = session.id logger.debug("terminal", "Destroying session for exited terminal: " .. session.id) - -- Only destroy if session still exists (may have been destroyed by another handler) if session_manager.get_session(session.id) then session_manager.destroy_session(session.id) end end end - -- Don't set terminal = nil yet, we might need it for fallback end vim.schedule(function() - -- If there are other sessions, switch to the new active session instead of closing + -- 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 1: check if any other terminal in our table is valid + -- 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 @@ -108,7 +107,7 @@ local function setup_terminal_events(term_instance, config, session_id) end end - -- Fallback 2: check the global terminal variable + -- 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 @@ -117,106 +116,44 @@ local function setup_terminal_events(term_instance, config, session_id) end end - -- Fallback 3: check session manager for terminal buffer and find matching terminal - if not new_term or not new_term:buf_valid() then - local session_data = session_manager.get_session(new_active_id) - if - session_data - and session_data.terminal_bufnr - and vim.api.nvim_buf_is_valid(session_data.terminal_bufnr) - then - -- Search all terminals for one with this buffer - for _, term in pairs(terminals) do - if term and term:buf_valid() and term.buf == session_data.terminal_bufnr then - new_term = term - terminals[new_active_id] = new_term - logger.debug("terminal", "Recovered terminal by buffer for session: " .. new_active_id) - break - end - end - -- Also check global terminal - if - (not new_term or not new_term:buf_valid()) - and terminal - and terminal:buf_valid() - and terminal.buf == session_data.terminal_bufnr - then - new_term = terminal - terminals[new_active_id] = new_term - logger.debug("terminal", "Recovered global terminal by buffer for session: " .. new_active_id) - end - end - end - if new_term and new_term:buf_valid() and new_term.buf then - -- Keep the window open and switch to the other session's buffer - if current_winid and vim.api.nvim_win_is_valid(current_winid) then - -- Disconnect old terminal instance from this window - -- (so it doesn't interfere when we delete its buffer) - term_instance.win = nil - - -- Switch the window to show the new session's buffer - vim.api.nvim_win_set_buf(current_winid, new_term.buf) - new_term.win = current_winid - - -- Notify terminal of window dimensions - local chan = vim.bo[new_term.buf].channel - if chan and chan > 0 then - local width = vim.api.nvim_win_get_width(current_winid) - local height = vim.api.nvim_win_get_height(current_winid) - pcall(vim.fn.jobresize, chan, width, height) - end + -- 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 + -- Update legacy terminal reference + terminal = new_term - -- Focus and enter insert mode - vim.api.nvim_set_current_win(current_winid) - if vim.api.nvim_buf_get_option(new_term.buf, "buftype") == "terminal" then - vim.cmd("startinsert") - end - - -- Re-attach tabbar + -- 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(current_winid, new_term.buf, new_term) + tabbar.attach(winid, new_term.buf, new_term) end + end - logger.debug("terminal", "Switched to session " .. new_active_id .. " in same window") - - -- Delete the old buffer after switching (buffer is no longer displayed) - if current_bufnr and vim.api.nvim_buf_is_valid(current_bufnr) then - vim.api.nvim_buf_delete(current_bufnr, { force = true }) - end + logger.debug("terminal", "Switched to session " .. new_active_id) - vim.cmd.checktime() - return - else - -- No valid window, show the other session using snacks toggle - logger.debug("terminal", "No valid window, showing session " .. new_active_id) - terminal = new_term - new_term:toggle() - if new_term.win and vim.api.nvim_win_is_valid(new_term.win) then - new_term:focus() - if new_term.buf and vim.api.nvim_buf_get_option(new_term.buf, "buftype") == "terminal" then - vim.api.nvim_win_call(new_term.win, function() - vim.cmd("startinsert") - end) - end - end - vim.cmd.checktime() - return + -- 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 normally - -- Clear terminal reference if this was the legacy terminal + -- No other sessions or couldn't switch, close the window if terminal == term_instance then terminal = nil end - term_instance:close({ buf = true }) + 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 }) @@ -233,17 +170,14 @@ local function setup_terminal_events(term_instance, config, session_id) if session_id then terminals[session_id] = nil - -- Destroy the session in session manager to prevent zombie sessions (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 logger.debug("terminal", "Destroying session for wiped terminal: " .. session.id) - -- Only destroy if session still exists (may have been destroyed by TermClose) if session_manager.get_session(session.id) then session_manager.destroy_session(session.id) end @@ -283,15 +217,12 @@ local function build_initial_title(session_id) return table.concat(parts, " | ") end ----Builds Snacks terminal options with focus control +---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) ---@param session_id string|nil Optional session ID for title ----@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, session_id) - focus = utils.normalize_focus(focus) - +---@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 = { @@ -308,7 +239,6 @@ local function build_opts(config, env_table, focus, session_id) } -- Only add exit_terminal keymap to Snacks keys if smart ESC handling is disabled - -- When smart ESC is enabled, we set up our own keymap after terminal creation 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 = { @@ -325,7 +255,7 @@ local function build_opts(config, env_table, focus, session_id) title = build_initial_title(session_id) end - -- Merge user's snacks_win_opts, preserving wo options for winbar support + -- Merge user's snacks_win_opts local win_opts = vim.tbl_deep_extend("force", { position = config.split_side, width = config.split_width_percentage, @@ -334,25 +264,74 @@ local function build_opts(config, env_table, focus, session_id) keys = keys, title = title, title_pos = title and "center" or nil, - -- Don't clear winbar - we set it dynamically for session tabs 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 = 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 + + 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 @@ -363,47 +342,27 @@ 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") @@ -411,24 +370,20 @@ function M.open(cmd_string, env_table, config, focus) terminal_module.setup_terminal_keymaps(term_instance.buf, config) end - -- Ensure a session exists before attaching tabbar (session is needed for tabbar content) - local session_id = session_manager.ensure_session() + -- Update session info session_manager.update_terminal_info(session_id, { bufnr = term_instance.buf, - winid = term_instance.win, + winid = window_manager.get_window(), }) - -- Attach tabbar directly with known window ID and snacks terminal instance - -- Use vim.schedule to ensure snacks has finished its window setup - if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then - local win_id = term_instance.win - local buf_id = term_instance.buf - local term_ref = term_instance + -- Attach tabbar + local winid = window_manager.get_window() + if winid then vim.schedule(function() - if vim.api.nvim_win_is_valid(win_id) then + if vim.api.nvim_win_is_valid(winid) then local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") if ok then - tabbar.attach(win_id, buf_id, term_ref) + tabbar.attach(winid, term_instance.buf, term_instance) end end end) @@ -436,27 +391,8 @@ function M.open(cmd_string, env_table, config, focus) 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 @@ -465,9 +401,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 @@ -480,17 +415,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") @@ -508,37 +443,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 @@ -577,35 +508,6 @@ end -- Multi-session support functions -- ============================================================================ ----Hide all visible session terminals ----@param except_session_id string|nil Optional session ID to exclude from hiding -local function hide_all_session_terminals(except_session_id) - for sid, term_instance in pairs(terminals) do - if sid ~= except_session_id and term_instance and term_instance:buf_valid() then - -- If terminal is visible, hide it - if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then - term_instance:toggle() - end - end - end - - -- Also hide the legacy terminal if it's different - if terminal and terminal:buf_valid() then - -- Check if legacy terminal is one of the session terminals - local is_session_terminal = false - for _, term_instance in pairs(terminals) do - if term_instance == terminal then - is_session_terminal = true - break - end - end - - if not is_session_terminal and terminal.win and vim.api.nvim_win_is_valid(terminal.win) then - terminal:toggle() - end - end -end - ---Open a terminal for a specific session ---@param session_id string The session ID ---@param cmd_string string The command to run @@ -619,51 +521,43 @@ function M.open_session(session_id, cmd_string, env_table, config, focus) 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 - -- Hide other session terminals first - hide_all_session_terminals(session_id) + -- Terminal exists, display it via window manager + window_manager.display_buffer(existing_term.buf, focus) - -- Terminal exists, show/focus it - if not existing_term.win or not vim.api.nvim_win_is_valid(existing_term.win) then - existing_term:toggle() - end - if focus then - existing_term:focus() - local term_buf_id = existing_term.buf - if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then - if existing_term.win and vim.api.nvim_win_is_valid(existing_term.win) then - vim.api.nvim_win_call(existing_term.win, function() - vim.cmd("startinsert") - end) - end + -- 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 - -- Hide all other session terminals before creating new one - hide_all_session_terminals(nil) - - -- Create new terminal for this session - local opts = build_opts(config, env_table, focus, session_id) - local term_instance = Snacks.terminal.open(cmd_string, opts) + -- 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 - setup_terminal_events(term_instance, config, session_id) terminals[session_id] = term_instance + terminal = term_instance -- Also set as legacy terminal - -- Also set as legacy terminal for backward compatibility - terminal = term_instance + -- 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 = term_instance.win, + winid = window_manager.get_window(), }) -- Set up smart ESC handling if enabled @@ -671,7 +565,7 @@ function M.open_session(session_id, cmd_string, env_table, config, focus) terminal_module.setup_terminal_keymaps(term_instance.buf, config) end - -- Setup OSC title handler to capture terminal title changes + -- Setup OSC title handler if term_instance.buf then osc_handler.setup_buffer_handler(term_instance.buf, function(title) if title and title ~= "" then @@ -680,16 +574,14 @@ function M.open_session(session_id, cmd_string, env_table, config, focus) end) end - -- Attach tabbar with snacks terminal instance for floating window title - if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then - local win_id = term_instance.win - local buf_id = term_instance.buf - local term_ref = term_instance + -- Attach tabbar + local winid = window_manager.get_window() + if winid then vim.schedule(function() - if vim.api.nvim_win_is_valid(win_id) then + if vim.api.nvim_win_is_valid(winid) then local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") if ok then - tabbar.attach(win_id, buf_id, term_ref) + tabbar.attach(winid, term_instance.buf, term_instance) end end end) @@ -712,7 +604,17 @@ function M.close_session(session_id) if term_instance and term_instance:buf_valid() then -- Mark as intentional close to suppress error message closing_sessions[session_id] = true - term_instance:close({ buf = true }) + + -- 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 @@ -732,71 +634,70 @@ function M.close_session_keep_window(old_session_id, new_session_id, effective_c 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 - return + 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 - -- Get the window from the old terminal - local target_winid = old_term.win - local had_visible_window = target_winid and vim.api.nvim_win_is_valid(target_winid) - - -- If new terminal exists, switch to it in the same window + -- If new terminal exists, display it via window manager if new_term and new_term:buf_valid() then - if had_visible_window and new_term.buf then - -- Set the new buffer in the existing window - vim.api.nvim_win_set_buf(target_winid, new_term.buf) - new_term.win = target_winid - - -- Notify terminal of window dimensions - local chan = vim.bo[new_term.buf].channel - if chan and chan > 0 then - local width = vim.api.nvim_win_get_width(target_winid) - local height = vim.api.nvim_win_get_height(target_winid) - pcall(vim.fn.jobresize, chan, width, height) - end + window_manager.display_buffer(new_term.buf, true) - -- Focus and enter insert mode - vim.api.nvim_set_current_win(target_winid) - if vim.api.nvim_buf_get_option(new_term.buf, "buftype") == "terminal" then - vim.cmd("startinsert") - end + -- Update legacy terminal reference + terminal = new_term - -- Update tabbar + -- Update tabbar + local winid = window_manager.get_window() + if winid then local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") if ok then - tabbar.attach(target_winid, new_term.buf, new_term) - end - elseif not had_visible_window then - -- Old window not visible, show new terminal - new_term:toggle() - if new_term.win and vim.api.nvim_win_is_valid(new_term.win) then - new_term:focus() - if new_term.buf and vim.api.nvim_buf_get_option(new_term.buf, "buftype") == "terminal" then - vim.api.nvim_win_call(new_term.win, function() - vim.cmd("startinsert") - end) - end + tabbar.attach(winid, new_term.buf, new_term) end end - - -- Update legacy terminal reference - terminal = new_term + 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 - -- Now close the old terminal's buffer (but window is already reused) - if old_term:buf_valid() then - -- Cleanup OSC handler + -- 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 - -- Close just the buffer, not the window - vim.api.nvim_buf_delete(old_term.buf, { force = true }) + 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 @@ -814,11 +715,12 @@ function M.focus_session(session_id, config) 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 - -- Check if legacy terminal matches the session's bufnr from session_manager local session_mod = require("claudecode.session") local session = session_mod.get_session(session_id) if @@ -828,7 +730,6 @@ function M.focus_session(session_id, config) and terminal:buf_valid() and terminal.buf == session.terminal_bufnr then - -- Legacy terminal matches this session, register it now logger.debug("terminal", "Registering legacy terminal for session: " .. session_id) M.register_terminal_for_session(session_id, terminal.buf) term_instance = terminals[session_id] @@ -840,40 +741,22 @@ function M.focus_session(session_id, config) end end - -- Hide other session terminals first - hide_all_session_terminals(session_id) + -- Display buffer via window manager + window_manager.display_buffer(term_instance.buf, true) - -- If terminal is hidden, show it - if not term_instance.win or not vim.api.nvim_win_is_valid(term_instance.win) then - term_instance:toggle() - end + -- Update legacy terminal reference + terminal = term_instance - -- Focus the terminal - term_instance:focus() - local term_buf_id = term_instance.buf - if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then - if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then - -- Notify terminal of window dimensions to fix cursor position after session switch - local chan = vim.bo[term_buf_id].channel - if chan and chan > 0 then - local width = vim.api.nvim_win_get_width(term_instance.win) - local height = vim.api.nvim_win_get_height(term_instance.win) - pcall(vim.fn.jobresize, chan, width, height) - end - - vim.api.nvim_win_call(term_instance.win, function() - vim.cmd("startinsert") - end) - end - end - - -- Update tabbar with the new terminal instance - if term_instance.win and vim.api.nvim_win_is_valid(term_instance.win) then + -- Update tabbar + local winid = window_manager.get_window() + if winid then local ok, tabbar = pcall(require, "claudecode.terminal.tabbar") if ok then - tabbar.attach(term_instance.win, term_instance.buf, term_instance) + 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 @@ -900,14 +783,11 @@ function M.get_active_session_ids() end ---Register an existing terminal (from legacy path) with a session ID ----This is called when a terminal was created via simple_toggle/focus_toggle ----and we need to associate it with a session for multi-session support. ---@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 no bufnr provided, use the legacy terminal if not term_bufnr and terminal and terminal:buf_valid() then term_bufnr = terminal.buf end @@ -917,10 +797,9 @@ function M.register_terminal_for_session(session_id, term_bufnr) return end - -- Check if this terminal is already registered to another session + -- 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 - -- Already registered to a different session, skip logger.debug( "terminal", "Terminal already registered to session " .. sid .. ", not registering to " .. session_id 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 From e28a7d983b70d2b3f7514ca19fbbfc45e61cb29a Mon Sep 17 00:00:00 2001 From: Snir Turgeman Date: Thu, 15 Jan 2026 08:16:10 +0200 Subject: [PATCH 6/8] docs: add fork features section and recommended configuration - Add "Fork Features" section highlighting multi-session support and visual tab bar features unique to this fork - Add "Recommended Configuration" with practical floating window setup - Update all repo references from coder/claudecode.nvim to snirt/claudecode.nvim - Add multi-session keymaps to installation example - Update CHANGELOG with new features and bug fixes --- CHANGELOG.md | 7 ++ README.md | 181 +++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 177 insertions(+), 11 deletions(-) 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", From 837dbb8a6fbf9c2c3dc28efcad9fd9e3c156ba24 Mon Sep 17 00:00:00 2001 From: Snir Turgeman Date: Tue, 20 Jan 2026 08:11:39 +0200 Subject: [PATCH 7/8] fix: prevent orphan Claude processes on Neovim exit Fixes #1 - Add defense-in-depth PID recovery from sessions and terminal buffers - Kill entire process tree (not just direct children) using process groups - Follow up with SIGKILL for any survivors after graceful SIGTERM - Add retry mechanism for PID tracking in snacks.lua (handles delayed job_id) - Track PIDs in external terminal provider - Add VimLeavePre autocmd to call cleanup_all() before server stops - Validate cleanup_strategy config option Tests: - Unit tests for defense-in-depth PID recovery - Integration tests with real processes verifying actual termination --- lua/claudecode/config.lua | 13 + lua/claudecode/init.lua | 6 + lua/claudecode/terminal.lua | 259 ++++++++++ lua/claudecode/terminal/external.lua | 15 +- lua/claudecode/terminal/native.lua | 66 +++ lua/claudecode/terminal/snacks.lua | 119 ++++- .../terminal_cleanup_integration_spec.lua | 253 ++++++++++ tests/integration/test_cleanup_real.lua | 289 +++++++++++ tests/unit/terminal_cleanup_spec.lua | 456 ++++++++++++++++++ tests/verify_cleanup.lua | 75 +++ 10 files changed, 1549 insertions(+), 2 deletions(-) create mode 100644 tests/integration/terminal_cleanup_integration_spec.lua create mode 100644 tests/integration/test_cleanup_real.lua create mode 100644 tests/unit/terminal_cleanup_spec.lua create mode 100644 tests/verify_cleanup.lua diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 5030a7da..6684538d 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -126,6 +126,19 @@ function M.validate(config) 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 623204d5..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 diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index f458c4cb..1151e7c7 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -9,6 +9,91 @@ 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 = { split_side = "right", @@ -33,6 +118,12 @@ local defaults = { -- 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 @@ -711,6 +802,18 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) 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 {} @@ -1195,4 +1298,160 @@ 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 2279f87c..970beba6 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -57,6 +57,31 @@ local function create_terminal_buffer(cmd_string, env_table, effective_config, s 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 term_cmd_arg = vim.split(cmd_string, " ", { plain = true, trimempty = false }) @@ -72,6 +97,11 @@ local function create_terminal_buffer(cmd_string, env_table, effective_config, s 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] @@ -198,6 +228,12 @@ local function create_terminal_buffer(cmd_string, env_table, effective_config, s return nil, nil end + -- 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 + return new_bufnr, new_jobid end @@ -248,6 +284,9 @@ function M.open(cmd_string, env_table, effective_config, focus) 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, @@ -432,6 +471,9 @@ function M.open_session(session_id, cmd_string, env_table, effective_config, foc 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 @@ -465,6 +507,18 @@ function M.close_session(session_id) 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) @@ -548,6 +602,18 @@ function M.close_session_keep_window(old_session_id, new_session_id, effective_c 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) diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 5a03c0a3..771e3088 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -188,6 +188,46 @@ local function setup_terminal_events(term_instance, config, session_id) end, { buf = true }) end +---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 @@ -320,6 +360,32 @@ local function create_terminal_buffer(cmd_string, env_table, config, session_id) 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 @@ -370,12 +436,21 @@ function M.open(cmd_string, env_table, config, focus) 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 @@ -560,11 +635,20 @@ function M.open_session(session_id, cmd_string, env_table, config, focus) 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) @@ -605,6 +689,21 @@ function M.close_session(session_id) -- 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) @@ -690,6 +789,22 @@ function M.close_session_keep_window(old_session_id, new_session_id, effective_c 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 @@ -701,7 +816,9 @@ function M.close_session_keep_window(old_session_id, new_session_id, effective_c end terminals[old_session_id] = nil - closing_sessions[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 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/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!") From d0772d7c1d57f8afee79dbdf62d22e70df8532bc Mon Sep 17 00:00:00 2001 From: Snir Turgeman Date: Wed, 21 Jan 2026 16:55:46 +0200 Subject: [PATCH 8/8] fix: update file reference on keyboard window navigation Add WinEnter autocommand to selection tracking so keyboard navigation (ctrl-h, ctrl-l, :wincmd) properly updates the file reference in Claude Code. Previously only mouse clicks triggered updates. The fix cancels pending debounce timers and uses a 10ms delay to ensure window/buffer state is settled, matching the existing mouse handler behavior. Closes #2 --- lua/claudecode/selection.lua | 28 +- tests/unit/selection_win_enter_spec.lua | 440 ++++++++++++++++++++++++ 2 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 tests/unit/selection_win_enter_spec.lua diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index 0f53f29a..7bc2d05f 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -57,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 }) @@ -82,6 +82,13 @@ 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. @@ -149,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. 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)