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..9ab03baa 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,165 @@ M.open_with_model = function(additional_args) end) end +---Show session picker UI for selecting between active sessions +function M.show_session_picker() + local terminal = require("claudecode.terminal") + local sessions = terminal.list_sessions() + + if #sessions == 0 then + logger.warn("command", "No active Claude Code sessions") + return + end + + local active_session_id = terminal.get_active_session_id() + + -- Format session items for display + local items = {} + for i, session in ipairs(sessions) do + local age = math.floor((vim.loop.now() - session.created_at) / 1000 / 60) + local age_str + if age < 1 then + age_str = "just now" + elseif age == 1 then + age_str = "1 min ago" + else + age_str = age .. " mins ago" + end + + local active_marker = session.id == active_session_id and " (active)" or "" + table.insert(items, { + index = i, + session = session, + display = string.format("[%d] %s - %s%s", i, session.name, age_str, active_marker), + }) + end + + -- Try to use available picker (Snacks, fzf-lua, or vim.ui.select) + local pick_ok = M._try_picker(items, function(item) + if item and item.session then + terminal.switch_to_session(item.session.id) + end + end) + + if not pick_ok then + -- Fallback to vim.ui.select + vim.ui.select(items, { + prompt = "Select Claude Code session:", + format_item = function(item) + return item.display + end, + }, function(choice) + if choice and choice.session then + terminal.switch_to_session(choice.session.id) + end + end) + end +end + +---Try to use an enhanced picker (Snacks or fzf-lua) +---@param items table[] Items to pick from +---@param on_select function Callback when item is selected +---@return boolean success Whether an enhanced picker was used +function M._try_picker(items, on_select) + -- Try Snacks picker first + local snacks_ok, Snacks = pcall(require, "snacks") + if snacks_ok and Snacks and Snacks.picker then + 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 + local display_items = {} + local item_map = {} + for _, item in ipairs(items) do + table.insert(display_items, item.display) + item_map[item.display] = item + end + + fzf.fzf_exec(display_items, { + prompt = "Claude Sessions> ", + actions = { + ["default"] = function(selected) + if selected and selected[1] then + local item = item_map[selected[1]] + if item then + on_select(item) + end + end + end, + ["ctrl-x"] = { + fn = function(selected) + if selected and selected[1] then + local item = item_map[selected[1]] + if item and item.session then + local terminal_mod = require("claudecode.terminal") + terminal_mod.close_session(item.session.id) + vim.notify("Closed session: " .. item.session.name, vim.log.levels.INFO) + 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 + 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..85caa592 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,412 @@ 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 + + -- 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") + 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 + + -- 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 + +---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..7e20bd39 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,269 @@ 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 + -- Notify terminal of window dimensions to fix cursor position after session switch + local chan = vim.bo[term_buf_id].channel + if chan and chan > 0 then + local width = vim.api.nvim_win_get_width(term_instance.win) + local height = vim.api.nvim_win_get_height(term_instance.win) + pcall(vim.fn.jobresize, chan, width, height) + end + + vim.api.nvim_win_call(term_instance.win, function() + vim.cmd("startinsert") + end) + end + end +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)