diff --git a/lua/claude-code/config.lua b/lua/claude-code/config.lua index 07d1a2c..5ca65ca 100644 --- a/lua/claude-code/config.lua +++ b/lua/claude-code/config.lua @@ -133,6 +133,10 @@ M.default_config = { }, window_navigation = true, -- Enable window navigation keymaps () scrolling = true, -- Enable scrolling keymaps () for page up/down + -- Note: For floating windows, additional keymaps are automatically added: + -- - in normal mode: Close the floating window + -- - q in normal mode: Close the floating window + -- - in terminal mode: Close the floating window }, } diff --git a/lua/claude-code/keymaps.lua b/lua/claude-code/keymaps.lua index 5441bd1..75e3a1b 100644 --- a/lua/claude-code/keymaps.lua +++ b/lua/claude-code/keymaps.lua @@ -108,6 +108,42 @@ function M.setup_terminal_navigation(claude_code, config) } ) + -- Add escape keymaps for floating windows + if config.window.position == 'float' then + local success, error_msg = pcall(function() + -- Escape key to close floating window in normal mode + vim.api.nvim_buf_set_keymap( + buf, + 'n', + '', + [[ClaudeCode]], + { noremap = true, silent = true, desc = 'Close Claude Code floating window' } + ) + + -- Also add common close keymaps + vim.api.nvim_buf_set_keymap( + buf, + 'n', + 'q', + [[ClaudeCode]], + { noremap = true, silent = true, desc = 'Close Claude Code floating window' } + ) + + -- Terminal mode escape sequence to close window + vim.api.nvim_buf_set_keymap( + buf, + 't', + '', + [[:ClaudeCode]], + { noremap = true, silent = true, desc = 'Close Claude Code floating window from terminal mode' } + ) + end) + + if not success then + vim.notify('Claude Code: Error setting up floating window keymaps: ' .. tostring(error_msg), vim.log.levels.WARN) + end + end + -- Window navigation keymaps if config.keymaps.window_navigation then -- Window navigation keymaps with special handling to force insert mode in the target window diff --git a/lua/claude-code/terminal.lua b/lua/claude-code/terminal.lua index e2fd643..aba3ac3 100644 --- a/lua/claude-code/terminal.lua +++ b/lua/claude-code/terminal.lua @@ -68,9 +68,12 @@ end --- Create a floating window for Claude Code --- @param config table Plugin configuration containing window settings --- @param existing_bufnr number|nil Buffer number of existing buffer to show in the float (optional) +--- @param claude_code table|nil Main plugin module for terminal job management (optional) +--- @param git table|nil Git module for building commands (optional) +--- @param instance_id string|nil Instance identifier for cleanup tracking (optional) --- @return number Window ID of the created floating window --- @private -local function create_float(config, existing_bufnr) +local function create_float(config, existing_bufnr, claude_code, git, instance_id) local float_config = config.window.float or {} -- Get editor dimensions (accounting for command line, status line, etc.) @@ -113,8 +116,120 @@ local function create_float(config, existing_bufnr) end end - -- Create and return the floating window - return vim.api.nvim_open_win(bufnr, true, win_config) + -- Create the floating window + local win_id = vim.api.nvim_open_win(bufnr, true, win_config) + + -- If we're reusing an existing buffer and have full context, check if terminal job is still running + if existing_bufnr and claude_code and git and instance_id then + local job_id = vim.b[bufnr].terminal_job_id + if not job_id or vim.fn.jobwait({job_id}, 0)[1] ~= -1 then + -- Terminal job is not running, start a new one with on_exit callback + local cmd = build_command_with_git_root(config, git, config.command) + + -- Ensure we got a valid command + if type(cmd) ~= "string" or cmd == "" then + vim.notify( + "Claude Code: Failed to build terminal command; aborting restart. cmd=" .. vim.inspect(cmd), + vim.log.levels.ERROR + ) + return win_id + end + + local new_job_id = vim.fn.termopen(cmd, { + on_exit = create_terminal_exit_handler(claude_code, instance_id, bufnr, win_id) + }) + end + end + + return win_id +end + +--- Handle buffer name collision by safely managing existing buffers +--- @param buffer_name string The intended buffer name +--- @param current_bufnr number The current buffer number +--- @return string The final buffer name to use (may be modified to avoid collision) +--- @private +local function handle_buffer_name_collision(buffer_name, current_bufnr) + local existing_bufnr = vim.fn.bufnr(buffer_name) + if existing_bufnr ~= -1 and existing_bufnr ~= current_bufnr then + -- Only delete if the buffer is valid and not currently displayed + if vim.api.nvim_buf_is_valid(existing_bufnr) then + local buf_windows = vim.fn.win_findbuf(existing_bufnr) + if #buf_windows == 0 then + -- Buffer exists but isn't displayed, safe to delete + vim.api.nvim_buf_delete(existing_bufnr, {force = true}) + else + -- Buffer is being displayed, use a different name with timestamp + -- Handle multiple levels of collision by trying different suffixes + local base_name = buffer_name + -- Use more granular timestamp to avoid collisions + local timestamp = tostring(os.time()) .. '-' .. tostring(math.random(1000, 9999)) + local attempt = 0 + repeat + if attempt == 0 then + buffer_name = base_name .. '-' .. timestamp + else + buffer_name = base_name .. '-' .. timestamp .. '-' .. attempt + end + existing_bufnr = vim.fn.bufnr(buffer_name) + attempt = attempt + 1 + until existing_bufnr == -1 or attempt > 10 -- Safety limit to prevent infinite loop + end + end + end + return buffer_name +end + +--- Create terminal exit handler for floating windows +--- @param claude_code table The main plugin module +--- @param instance_id string The instance identifier +--- @param bufnr number The buffer number +--- @param win_id number The window ID +--- @return function The exit handler function +--- @private +local function create_terminal_exit_handler(claude_code, instance_id, bufnr, win_id) + return function(job_id_exit, exit_code, event_type) + vim.schedule(function() + -- Robust cleanup with error handling + local success, error_msg = pcall(function() + -- Clean up instance tracking first + if claude_code.claude_code.instances[instance_id] == bufnr then + claude_code.claude_code.instances[instance_id] = nil + end + + -- Check if window is still valid before attempting to close + if vim.api.nvim_win_is_valid(win_id) then + -- Get the config to confirm it's still a floating window + local win_config = vim.api.nvim_win_get_config(win_id) + if win_config.relative ~= '' then + -- This is indeed a floating window, close it + vim.api.nvim_win_close(win_id, true) + + -- Check if we need to create a fallback window + local remaining_wins = vim.api.nvim_list_wins() + local has_normal_window = false + for _, w in ipairs(remaining_wins) do + local cfg = vim.api.nvim_win_get_config(w) + if cfg.relative == '' then + has_normal_window = true + break + end + end + + -- If no normal windows remain, create one + if not has_normal_window then + vim.cmd('new') + end + end + end + end) + + -- Log any errors for debugging + if not success then + vim.notify('Claude Code: Error in terminal exit handler: ' .. tostring(error_msg), vim.log.levels.WARN) + end + end) + end end --- Build command with git root directory if configured @@ -184,7 +299,7 @@ end local function create_split(position, config, existing_bufnr) -- Handle floating window if position == 'float' then - return create_float(config, existing_bufnr) + return create_float(config, existing_bufnr, nil, nil, nil) end local is_vertical = position:match('vsplit') or position:match('vertical') @@ -290,17 +405,37 @@ end --- @param bufnr number Buffer number --- @param config table Plugin configuration --- @private -local function handle_existing_instance(bufnr, config) +local function handle_existing_instance(bufnr, config, claude_code, git, instance_id) local win_ids = vim.fn.win_findbuf(bufnr) if #win_ids > 0 then -- Claude Code is visible, close the window for _, win_id in ipairs(win_ids) do - vim.api.nvim_win_close(win_id, true) + -- For floating windows, ensure we have a valid window to return to + local win_config = vim.api.nvim_win_get_config(win_id) + if win_config.relative ~= '' then + -- This is a floating window + -- Store the current window that will be focused after closing + local current_win = vim.api.nvim_get_current_win() + + -- Close the floating window + vim.api.nvim_win_close(win_id, true) + + -- Ensure we have a valid window after closing + -- This handles the case where the underlying window might have been closed + local remaining_wins = vim.api.nvim_list_wins() + if #remaining_wins == 0 then + -- No windows left, create a new one + vim.cmd('enew') + end + else + -- Regular window, just close it + vim.api.nvim_win_close(win_id, true) + end end else -- Claude Code buffer exists but is not visible, open it in a split or float if config.window.position == 'float' then - create_float(config, bufnr) + create_float(config, bufnr, claude_code, git, instance_id) else create_split(config.window.position, config, bufnr) end @@ -326,7 +461,7 @@ local function create_new_instance(claude_code, config, git, instance_id) vim.api.nvim_set_option_value('bufhidden', 'hide', {buf = new_bufnr}) -- Create the floating window - local win_id = create_float(config, new_bufnr) + local win_id = create_float(config, new_bufnr, claude_code, git, instance_id) -- Set current buffer to run terminal command vim.api.nvim_win_set_buf(win_id, new_bufnr) @@ -335,10 +470,13 @@ local function create_new_instance(claude_code, config, git, instance_id) local cmd = build_command_with_git_root(config, git, config.command) -- Run terminal in the buffer - vim.fn.termopen(cmd) + local job_id = vim.fn.termopen(cmd, { + on_exit = create_terminal_exit_handler(claude_code, instance_id, new_bufnr, win_id) + }) -- Create a unique buffer name local buffer_name = generate_buffer_name(instance_id, config) + buffer_name = handle_buffer_name_collision(buffer_name, new_bufnr) vim.api.nvim_buf_set_name(new_bufnr, buffer_name) -- Configure window options @@ -364,6 +502,8 @@ local function create_new_instance(claude_code, config, git, instance_id) -- Create a unique buffer name local buffer_name = generate_buffer_name(instance_id, config) + local current_bufnr = vim.api.nvim_get_current_buf() + buffer_name = handle_buffer_name_collision(buffer_name, current_bufnr) vim.cmd('file ' .. buffer_name) -- Configure window options using helper function @@ -401,7 +541,7 @@ function M.toggle(claude_code, config, git) if bufnr and vim.api.nvim_buf_is_valid(bufnr) then -- Handle existing instance (toggle visibility) - handle_existing_instance(bufnr, config) + handle_existing_instance(bufnr, config, claude_code, git, instance_id) else -- Prune invalid buffer entries if bufnr and not vim.api.nvim_buf_is_valid(bufnr) then diff --git a/tests/spec/terminal_buffer_name_collision_spec.lua b/tests/spec/terminal_buffer_name_collision_spec.lua new file mode 100644 index 0000000..4dd29a6 --- /dev/null +++ b/tests/spec/terminal_buffer_name_collision_spec.lua @@ -0,0 +1,528 @@ +-- Tests for buffer name collision handling in terminal.lua +local assert = require('luassert') +local describe = require('plenary.busted').describe +local it = require('plenary.busted').it + +local terminal = require('claude-code.terminal') + +describe('terminal buffer name collision handling', function() + local config + local claude_code + local git + local vim_cmd_calls = {} + local existing_buffers = {} + local deleted_buffers = {} + local nvim_buf_set_name_calls = {} + local original_os_time = os.time + + -- Mock os.time for consistent timestamps + os.time = function() + return 1234567890 + end + + before_each(function() + -- Reset tracking variables + vim_cmd_calls = {} + existing_buffers = {} + deleted_buffers = {} + nvim_buf_set_name_calls = {} + + -- Mock vim functions + _G.vim = _G.vim or {} + _G.vim.api = _G.vim.api or {} + _G.vim.fn = _G.vim.fn or {} + _G.vim.bo = _G.vim.bo or {} + _G.vim.o = setmetatable({ + lines = 100, + columns = 100, + cmdheight = 1 + }, { + __newindex = function(t, k, v) + if k == 'lines' or k == 'columns' or k == 'cmdheight' then + rawset(t, k, tonumber(v) or rawget(t, k) or 1) + else + rawset(t, k, v) + end + end + }) + + -- Mock vim.cmd + _G.vim.cmd = function(cmd) + table.insert(vim_cmd_calls, cmd) + return true + end + + -- Mock vim.api.nvim_buf_is_valid + _G.vim.api.nvim_buf_is_valid = function(bufnr) + return bufnr ~= nil and bufnr > 0 and not deleted_buffers[bufnr] + end + + -- Mock vim.api.nvim_get_option_value (new API) + _G.vim.api.nvim_get_option_value = function(option, opts) + if option == 'buftype' then + return 'terminal' -- Always return terminal for valid buffers in tests + end + return '' + end + + -- Mock vim.b for buffer variables (new API) + _G.vim.b = setmetatable({}, { + __index = function(t, bufnr) + if not rawget(t, bufnr) then + rawset(t, bufnr, { + terminal_job_id = 12345 -- Mock job ID + }) + end + return rawget(t, bufnr) + end + }) + + -- Mock vim.api.nvim_set_option_value (new API for both buffer and window options) + _G.vim.api.nvim_set_option_value = function(option, value, opts) + -- Just mock this to do nothing for tests + return true + end + + -- Mock vim.fn.jobwait + _G.vim.fn.jobwait = function(job_ids, timeout) + return {-1} -- -1 means job is still running + end + + -- Note: os.time mocking is done per test case as needed + + -- Mock vim.api.nvim_buf_delete + _G.vim.api.nvim_buf_delete = function(bufnr, opts) + deleted_buffers[bufnr] = true + existing_buffers[bufnr] = nil + return true + end + + -- Mock vim.api.nvim_buf_set_name + _G.vim.api.nvim_buf_set_name = function(bufnr, name) + table.insert(nvim_buf_set_name_calls, {bufnr = bufnr, name = name}) + -- Check if buffer name already exists and throw error if it does + for existing_bufnr, existing_name in pairs(existing_buffers) do + if existing_name == name and existing_bufnr ~= bufnr and not deleted_buffers[existing_bufnr] then + error('Vim:E95: Buffer with this name already exists') + end + end + existing_buffers[bufnr] = name + return true + end + + -- Mock vim.fn.bufnr + _G.vim.fn.bufnr = function(pattern) + if pattern == '%' then + return 42 + end + -- Check if buffer with this name exists + for bufnr, name in pairs(existing_buffers) do + if name == pattern and not deleted_buffers[bufnr] then + return bufnr + end + end + return -1 + end + + -- Mock vim.fn.win_findbuf + _G.vim.fn.win_findbuf = function(bufnr) + -- For testing, assume buffers are not displayed unless specified + return {} + end + + -- Mock vim.fn.getcwd + _G.vim.fn.getcwd = function() + return '/test/current/dir' + end + + -- Mock vim.api.nvim_get_current_buf + _G.vim.api.nvim_get_current_buf = function() + return 42 + end + + -- Mock vim.api.nvim_create_buf + local buffer_counter = 100 + _G.vim.api.nvim_create_buf = function(listed, scratch) + buffer_counter = buffer_counter + 1 + existing_buffers[buffer_counter] = nil -- Track the buffer as existing + return buffer_counter + end + + -- Mock vim.api.nvim_open_win + _G.vim.api.nvim_open_win = function(buf, enter, config) + return 123 + end + + -- Mock vim.api.nvim_set_option_value + _G.vim.api.nvim_set_option_value = function(option, value, opts) + return true + end + + -- Mock vim.api.nvim_win_set_buf + _G.vim.api.nvim_win_set_buf = function(win_id, bufnr) + return true + end + + -- Mock vim.fn.termopen + _G.vim.fn.termopen = function(cmd, opts) + return 12345 + end + + -- Mock additional window/buffer functions + _G.vim.api.nvim_win_is_valid = function(win_id) + return win_id ~= nil and win_id > 0 + end + + _G.vim.api.nvim_get_current_win = function() + return 200 + end + + _G.vim.api.nvim_win_close = function(win_id, force) + return true + end + + _G.vim.api.nvim_list_wins = function() + return {200, 201, 202} + end + + _G.vim.api.nvim_win_get_config = function(win_id) + return {relative = ''} + end + + -- Setup test objects + config = { + command = 'claude', + window = { + position = 'float', + split_ratio = 0.5, + enter_insert = true, + start_in_normal_mode = false, + hide_numbers = true, + hide_signcolumn = true, + float = { + width = 80, + height = 20, + relative = 'editor', + border = 'rounded' + } + }, + git = { + use_git_root = true, + multi_instance = true, + }, + shell = { + separator = '&&', + pushd_cmd = 'pushd', + popd_cmd = 'popd', + }, + } + + claude_code = { + claude_code = { + instances = {}, + current_instance = nil, + saved_updatetime = nil, + }, + } + + git = { + get_git_root = function() + return '/test/git/root' + end, + } + end) + + after_each(function() + -- Restore original os.time + os.time = original_os_time + end) + + describe('buffer name collision in floating window', function() + it('should handle existing buffer with same name gracefully', function() + -- Create a buffer with the name that would be generated + local expected_name = 'claude-code-/test/git/root' + local sanitized_name = expected_name:gsub('[^%w%-_]', '-') + local existing_bufnr = 50 + existing_buffers[existing_bufnr] = sanitized_name + + -- Configure floating window + config.window.position = 'float' + + -- Ensure no instances exist initially + claude_code.claude_code.instances = {} + + -- Call toggle - this should handle the collision gracefully + local success, error_msg = pcall(function() + terminal.toggle(claude_code, config, git) + end) + + -- Should succeed without throwing the E95 error + assert.is_true(success, 'toggle should succeed when handling buffer name collision: ' .. (error_msg or '')) + + -- Should have deleted the existing buffer + assert.is_true(deleted_buffers[existing_bufnr], 'existing buffer should be deleted') + + -- Should have created a new buffer with the same name + local found_set_name = false + for _, call in ipairs(nvim_buf_set_name_calls) do + if call.name == sanitized_name then + found_set_name = true + break + end + end + assert.is_true(found_set_name, 'new buffer should be created with the intended name') + end) + + it('should use different name when existing buffer is displayed', function() + -- Create a buffer with the name that would be generated + local expected_name = 'claude-code-/test/git/root' + local sanitized_name = expected_name:gsub('[^%w%-_]', '-') + local existing_bufnr = 50 + existing_buffers[existing_bufnr] = sanitized_name + + -- Mock os.time for predictable timestamps in this test + local original_time = os.time + os.time = function() + return 1234567890 -- Fixed timestamp for testing + end + + -- Mock win_findbuf to return windows (buffer is displayed) + _G.vim.fn.win_findbuf = function(bufnr) + if bufnr == existing_bufnr then + return {100, 101} -- Two windows displaying this buffer + end + return {} + end + + -- Configure floating window + config.window.position = 'float' + + -- Ensure no instances exist initially + claude_code.claude_code.instances = {} + + -- Call toggle + local success, error_msg = pcall(function() + terminal.toggle(claude_code, config, git) + end) + + -- Should succeed without throwing the E95 error + assert.is_true(success, 'toggle should succeed when buffer is displayed: ' .. (error_msg or '')) + + -- Should NOT have deleted the existing buffer (it's displayed) + assert.is_false(deleted_buffers[existing_bufnr] or false, 'displayed buffer should not be deleted') + + -- Should have created a new buffer with a different name (timestamped) + -- The new format includes a random number: name-timestamp-random + local found_timestamped_name = false + local actual_name = nil + for _, call in ipairs(nvim_buf_set_name_calls) do + -- Check if the name matches the pattern: base-name-timestamp-random + if call.name:match('^' .. vim.pesc(sanitized_name) .. '%-1234567890%-(%d+)$') then + found_timestamped_name = true + actual_name = call.name + break + end + end + assert.is_true(found_timestamped_name, 'new buffer should be created with timestamped name pattern: ' .. sanitized_name .. '-1234567890-XXXX, but got: ' .. (actual_name or 'none')) + + -- Restore original os.time + os.time = original_time + end) + end) + + describe('buffer name collision in split window', function() + it('should handle existing buffer with same name gracefully', function() + -- Create a buffer with the name that would be generated + local expected_name = 'claude-code-/test/git/root' + local sanitized_name = expected_name:gsub('[^%w%-_]', '-') + local existing_bufnr = 60 + existing_buffers[existing_bufnr] = sanitized_name + + -- Configure split window + config.window.position = 'botright' + + -- Ensure no instances exist initially + claude_code.claude_code.instances = {} + + -- Call toggle - this should handle the collision gracefully + local success, error_msg = pcall(function() + terminal.toggle(claude_code, config, git) + end) + + -- Should succeed without throwing the E95 error + assert.is_true(success, 'toggle should succeed when handling buffer name collision: ' .. (error_msg or '')) + + -- Should have deleted the existing buffer + assert.is_true(deleted_buffers[existing_bufnr], 'existing buffer should be deleted') + + -- Should have created a new buffer with the same name using 'file' command + local found_file_cmd = false + for _, cmd in ipairs(vim_cmd_calls) do + if cmd == 'file ' .. sanitized_name then + found_file_cmd = true + break + end + end + assert.is_true(found_file_cmd, 'file command should be called with the intended name') + end) + + it('should use different name when existing buffer is displayed', function() + -- Create a buffer with the name that would be generated + local expected_name = 'claude-code-/test/git/root' + local sanitized_name = expected_name:gsub('[^%w%-_]', '-') + local existing_bufnr = 60 + existing_buffers[existing_bufnr] = sanitized_name + + -- Mock os.time for predictable timestamps in this test + local original_time = os.time + os.time = function() + return 1234567890 -- Fixed timestamp for testing + end + + -- Mock win_findbuf to return windows (buffer is displayed) + _G.vim.fn.win_findbuf = function(bufnr) + if bufnr == existing_bufnr then + return {200, 201} -- Two windows displaying this buffer + end + return {} + end + + -- Configure split window + config.window.position = 'botright' + + -- Ensure no instances exist initially + claude_code.claude_code.instances = {} + + -- Call toggle + local success, error_msg = pcall(function() + terminal.toggle(claude_code, config, git) + end) + + -- Should succeed without throwing the E95 error + assert.is_true(success, 'toggle should succeed when buffer is displayed: ' .. (error_msg or '')) + + -- Should NOT have deleted the existing buffer (it's displayed) + assert.is_false(deleted_buffers[existing_bufnr] or false, 'displayed buffer should not be deleted') + + -- Should have created a new buffer with a different name (timestamped) + -- The new format includes a random number: name-timestamp-random + local found_timestamped_file_cmd = false + local actual_cmd = nil + for _, cmd in ipairs(vim_cmd_calls) do + -- Check if the command matches the pattern: file base-name-timestamp-random + if cmd:match('^file ' .. vim.pesc(sanitized_name) .. '%-1234567890%-(%d+)$') then + found_timestamped_file_cmd = true + actual_cmd = cmd + break + end + end + assert.is_true(found_timestamped_file_cmd, 'file command should be called with timestamped name pattern: file ' .. sanitized_name .. '-1234567890-XXXX, but got: ' .. (actual_cmd or 'none')) + + -- Restore original os.time + os.time = original_time + end) + end) + + describe('buffer name collision edge cases', function() + it('should handle invalid existing buffer gracefully', function() + -- Create a buffer with the name that would be generated, but mark it as invalid + local expected_name = 'claude-code-/test/git/root' + local sanitized_name = expected_name:gsub('[^%w%-_]', '-') + local existing_bufnr = 70 + existing_buffers[existing_bufnr] = sanitized_name + deleted_buffers[existing_bufnr] = true -- Mark as deleted/invalid + + -- Configure floating window + config.window.position = 'float' + + -- Ensure no instances exist initially + claude_code.claude_code.instances = {} + + -- Call toggle + local success, error_msg = pcall(function() + terminal.toggle(claude_code, config, git) + end) + + -- Should succeed without throwing the E95 error + assert.is_true(success, 'toggle should succeed when existing buffer is invalid: ' .. (error_msg or '')) + + -- Should have created a new buffer with the intended name + local found_set_name = false + for _, call in ipairs(nvim_buf_set_name_calls) do + if call.name == sanitized_name then + found_set_name = true + break + end + end + assert.is_true(found_set_name, 'new buffer should be created with the intended name') + end) + + it('should handle multiple collisions by using timestamp', function() + -- Create a buffer with the name that would be generated + local expected_name = 'claude-code-/test/git/root' + local sanitized_name = expected_name:gsub('[^%w%-_]', '-') + local existing_bufnr = 80 + existing_buffers[existing_bufnr] = sanitized_name + + -- Also create a buffer with the timestamped name (simulate second collision) + -- Use our mocked timestamp value + local timestamp = 1234567890 + local timestamped_name = sanitized_name .. '-' .. timestamp .. '-1234' -- Match new format + local existing_timestamped_bufnr = 81 + existing_buffers[existing_timestamped_bufnr] = timestamped_name + + -- Mock win_findbuf to return windows for both buffers (both are displayed) + _G.vim.fn.win_findbuf = function(bufnr) + if bufnr == existing_bufnr or bufnr == existing_timestamped_bufnr then + return {300} -- One window displaying this buffer + end + return {} + end + + -- Mock os.time to return predictable timestamp + local original_time = os.time + os.time = function() + return timestamp + end + + -- Configure floating window + config.window.position = 'float' + + -- Ensure no instances exist initially + claude_code.claude_code.instances = {} + + -- Call toggle + local success, error_msg = pcall(function() + terminal.toggle(claude_code, config, git) + end) + + -- Restore original os.time + os.time = original_time + + -- Should succeed without throwing the E95 error + assert.is_true(success, 'toggle should succeed when handling multiple collisions: ' .. (error_msg or '')) + + -- Should NOT have deleted the existing buffers (they're displayed) + assert.is_false(deleted_buffers[existing_bufnr] or false, 'first buffer should not be deleted') + assert.is_false(deleted_buffers[existing_timestamped_bufnr] or false, 'timestamped buffer should not be deleted') + + -- Should have created a new buffer with a timestamped name + -- The new format uses timestamp-random, and since both existing names are taken, + -- it will generate a fresh timestamp-random combination + local found_timestamped_name = false + local actual_name = nil + + -- Check for the new timestamp pattern + local pattern = vim.pesc(sanitized_name) .. '%-1234567890%-(%d+)$' + for _, call in ipairs(nvim_buf_set_name_calls) do + if call.name:match(pattern) and call.name ~= timestamped_name then + found_timestamped_name = true + actual_name = call.name + break + end + end + + assert.is_true(found_timestamped_name, 'new buffer should be created with timestamped name pattern: ' .. sanitized_name .. '-1234567890-XXXX, but got: ' .. (actual_name or 'none')) + end) + end) +end) \ No newline at end of file