From 8f8373b81131ed8ed0d8d38cc4e4b1db5bcb4b5b Mon Sep 17 00:00:00 2001 From: Snir Turgeman Date: Wed, 17 Dec 2025 18:06:27 +0200 Subject: [PATCH 1/2] 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/2] 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)