diff --git a/lua/nvim-claude/commands.lua b/lua/nvim-claude/commands.lua deleted file mode 100644 index efed4d03..00000000 --- a/lua/nvim-claude/commands.lua +++ /dev/null @@ -1,1642 +0,0 @@ --- Commands module for nvim-claude -local M = {} - --- Reference to main module -local claude = nil - --- Setup commands -function M.setup(claude_module) - claude = claude_module - - -- ClaudeChat command - vim.api.nvim_create_user_command('ClaudeChat', function() - M.claude_chat() - end, { - desc = 'Open Claude in a tmux pane' - }) - - -- ClaudeSendBuffer command - vim.api.nvim_create_user_command('ClaudeSendBuffer', function() - M.send_buffer() - end, { - desc = 'Send current buffer to Claude' - }) - - -- ClaudeSendSelection command (visual mode) - vim.api.nvim_create_user_command('ClaudeSendSelection', function(opts) - M.send_selection(opts.line1, opts.line2) - end, { - desc = 'Send selected text to Claude', - range = true - }) - - -- ClaudeSendHunk command - vim.api.nvim_create_user_command('ClaudeSendHunk', function() - M.send_hunk() - end, { - desc = 'Send git hunk under cursor to Claude' - }) - - -- ClaudeBg command - vim.api.nvim_create_user_command('ClaudeBg', function(opts) - M.claude_bg(opts.args) - end, { - desc = 'Start a background Claude agent', - nargs = '*', -- Changed from '+' to '*' to allow zero arguments - complete = function() return {} end - }) - - -- ClaudeAgents command - vim.api.nvim_create_user_command('ClaudeAgents', function() - M.list_agents() - end, { - desc = 'List all Claude agents' - }) - - -- ClaudeKill command - vim.api.nvim_create_user_command('ClaudeKill', function(opts) - M.kill_agent(opts.args) - end, { - desc = 'Kill a Claude agent', - nargs = '?', - complete = function() - local agents = claude.registry.get_project_agents() - local completions = {} - for id, agent in pairs(agents) do - if agent.status == 'active' then - table.insert(completions, id) - end - end - return completions - end - }) - - -- ClaudeClean command - vim.api.nvim_create_user_command('ClaudeClean', function() - M.clean_agents() - end, { - desc = 'Clean up old Claude agents' - }) - - -- ClaudeDebug command - vim.api.nvim_create_user_command('ClaudeDebug', function() - M.debug_panes() - end, { - desc = 'Debug Claude pane detection' - }) - - -- ClaudeSwitch command (DEPRECATED - use ClaudeAgents instead) - vim.api.nvim_create_user_command('ClaudeSwitch', function(opts) - vim.notify('ClaudeSwitch is deprecated. Use :ClaudeAgents instead.', vim.log.levels.WARN) - -- Redirect to ClaudeAgents - M.list_agents() - end, { - desc = '[DEPRECATED] Use :ClaudeAgents instead', - nargs = '?', - }) - - -- ClaudeDebugAgents command - vim.api.nvim_create_user_command('ClaudeDebugAgents', function() - local current_dir = vim.fn.getcwd() - local project_root = claude.utils.get_project_root() - local all_agents = claude.registry.agents - local project_agents = claude.registry.get_project_agents() - - vim.notify(string.format('Current directory: %s', current_dir), vim.log.levels.INFO) - vim.notify(string.format('Project root: %s', project_root), vim.log.levels.INFO) - vim.notify(string.format('Total agents in registry: %d', vim.tbl_count(all_agents)), vim.log.levels.INFO) - vim.notify(string.format('Project agents count: %d', #project_agents), vim.log.levels.INFO) - - for id, agent in pairs(all_agents) do - vim.notify(string.format(' Agent %s: project=%s, status=%s, task=%s', - id, agent.project_root or 'nil', agent.status, - (agent.task or ''):match('[^\n]*') or agent.task), vim.log.levels.INFO) - end - end, { - desc = 'Debug agent registry' - }) - - -- ClaudeCleanOrphans command - clean orphaned directories - vim.api.nvim_create_user_command('ClaudeCleanOrphans', function() - M.clean_orphaned_directories() - end, { - desc = 'Clean orphaned agent directories' - }) - - -- ClaudeDiffAgent command - vim.api.nvim_create_user_command('ClaudeDiffAgent', function(opts) - M.diff_agent(opts.args ~= '' and opts.args or nil) - end, { - desc = 'Review agent changes with diffview', - nargs = '?', - complete = function() - local agents = claude.registry.get_project_agents() - local completions = {} - for _, agent in ipairs(agents) do - table.insert(completions, agent.id) - end - return completions - end - }) - - -- Debug command to check registry state - vim.api.nvim_create_user_command('ClaudeDebugRegistry', function() - local project_root = claude.utils.get_project_root() - local current_dir = vim.fn.getcwd() - local all_agents = claude.registry.agents - local project_agents = claude.registry.get_project_agents() - - vim.notify('=== Claude Registry Debug ===', vim.log.levels.INFO) - vim.notify('Current directory: ' .. current_dir, vim.log.levels.INFO) - vim.notify('Project root: ' .. project_root, vim.log.levels.INFO) - vim.notify('Total agents in registry: ' .. vim.tbl_count(all_agents), vim.log.levels.INFO) - vim.notify('Agents for current project: ' .. #project_agents, vim.log.levels.INFO) - - if vim.tbl_count(all_agents) > 0 then - vim.notify('\nAll agents:', vim.log.levels.INFO) - for id, agent in pairs(all_agents) do - vim.notify(string.format(' %s: project_root=%s, work_dir=%s', - id, agent.project_root, agent.work_dir), vim.log.levels.INFO) - end - end - end, { - desc = 'Debug Claude registry state' - }) -end - --- Open Claude chat -function M.claude_chat() - if not claude.tmux.validate() then - return - end - - local pane_id = claude.tmux.create_pane('claude') - if pane_id then - vim.notify('Claude chat opened in pane ' .. pane_id, vim.log.levels.INFO) - else - vim.notify('Failed to create Claude pane', vim.log.levels.ERROR) - end -end - --- Send current buffer to Claude -function M.send_buffer() - if not claude.tmux.validate() then - return - end - - local pane_id = claude.tmux.find_claude_pane() - if not pane_id then - pane_id = claude.tmux.create_pane('claude') - end - - if not pane_id then - vim.notify('Failed to find or create Claude pane', vim.log.levels.ERROR) - return - end - - -- Get buffer info - local filename = vim.fn.expand('%:t') - local filetype = vim.bo.filetype - local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) - - -- Build complete message as one string - local message_parts = { - string.format('Here is `%s` (%s):', filename, filetype), - '```' .. (filetype ~= '' and filetype or ''), - } - - -- Add all lines - for _, line in ipairs(lines) do - table.insert(message_parts, line) - end - - table.insert(message_parts, '```') - - -- Send as one batched message - local full_message = table.concat(message_parts, '\n') - claude.tmux.send_text_to_pane(pane_id, full_message) - - vim.notify('Buffer sent to Claude', vim.log.levels.INFO) -end - --- Send selection to Claude -function M.send_selection(line1, line2) - if not claude.tmux.validate() then - return - end - - local pane_id = claude.tmux.find_claude_pane() - if not pane_id then - pane_id = claude.tmux.create_pane('claude') - end - - if not pane_id then - vim.notify('Failed to find or create Claude pane', vim.log.levels.ERROR) - return - end - - -- Get selected lines - local lines = vim.api.nvim_buf_get_lines(0, line1 - 1, line2, false) - - -- Build complete message as one string - local filename = vim.fn.expand('%:t') - local filetype = vim.bo.filetype - local message_parts = { - string.format('Selection from `%s` (lines %d-%d):', filename, line1, line2), - '```' .. (filetype ~= '' and filetype or ''), - } - - -- Add all lines - for _, line in ipairs(lines) do - table.insert(message_parts, line) - end - - table.insert(message_parts, '```') - - -- Send as one batched message - local full_message = table.concat(message_parts, '\n') - claude.tmux.send_text_to_pane(pane_id, full_message) - - vim.notify('Selection sent to Claude', vim.log.levels.INFO) -end - --- Send git hunk under cursor to Claude -function M.send_hunk() - if not claude.tmux.validate() then - return - end - - local pane_id = claude.tmux.find_claude_pane() - if not pane_id then - pane_id = claude.tmux.create_pane('claude') - end - - if not pane_id then - vim.notify('Failed to find or create Claude pane', vim.log.levels.ERROR) - return - end - - -- Get current cursor position - local cursor_pos = vim.api.nvim_win_get_cursor(0) - local current_line = cursor_pos[1] - - -- Get git diff to find hunks - local filename = vim.fn.expand('%:p') - local relative_filename = vim.fn.expand('%') - - if not filename or filename == '' then - vim.notify('No file to get hunk from', vim.log.levels.ERROR) - return - end - - -- Get git diff for this file - local cmd = string.format('git diff HEAD -- "%s"', filename) - local diff_output = claude.utils.exec(cmd) - - if not diff_output or diff_output == '' then - vim.notify('No git changes found in current file', vim.log.levels.INFO) - return - end - - -- Parse diff to find hunk containing current line - local hunk_lines = {} - local hunk_start = nil - local hunk_end = nil - local found_hunk = false - - for line in diff_output:gmatch('[^\n]+') do - if line:match('^@@') then - -- Parse hunk header: @@ -oldstart,oldcount +newstart,newcount @@ - local newstart, newcount = line:match('^@@ %-%d+,%d+ %+(%d+),(%d+) @@') - if newstart and newcount then - newstart = tonumber(newstart) - newcount = tonumber(newcount) - - if current_line >= newstart and current_line < newstart + newcount then - found_hunk = true - hunk_start = newstart - hunk_end = newstart + newcount - 1 - table.insert(hunk_lines, line) -- Include the @@ line - else - found_hunk = false - hunk_lines = {} - end - end - elseif found_hunk then - table.insert(hunk_lines, line) - end - end - - if #hunk_lines == 0 then - vim.notify('No git hunk found at cursor position', vim.log.levels.INFO) - return - end - - -- Build message - local message_parts = { - string.format('Git hunk from `%s` (around line %d):', relative_filename, current_line), - '```diff' - } - - -- Add hunk lines - for _, line in ipairs(hunk_lines) do - table.insert(message_parts, line) - end - - table.insert(message_parts, '```') - - -- Send as one batched message - local full_message = table.concat(message_parts, '\n') - claude.tmux.send_text_to_pane(pane_id, full_message) - - vim.notify('Git hunk sent to Claude', vim.log.levels.INFO) -end - --- Start background agent with UI -function M.claude_bg(task) - if not claude.tmux.validate() then - return - end - - -- If task provided via command line, use old flow for backwards compatibility - if task and task ~= '' then - M.create_agent_from_task(task, nil) - return - end - - -- Otherwise show the new UI - M.show_agent_creation_ui() -end - --- Show agent creation UI -function M.show_agent_creation_ui() - -- Start with mission input stage - M.show_mission_input_ui() -end - --- Stage 1: Mission input with multiline support -function M.show_mission_input_ui() - -- Create buffer for mission input - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_option(buf, 'buftype', 'nofile') - vim.api.nvim_buf_set_option(buf, 'bufhidden', 'wipe') - vim.api.nvim_buf_set_option(buf, 'filetype', 'markdown') -- Nice syntax highlighting - - -- Initial content with placeholder - local lines = { - '# Agent Mission Description', - '', - 'Enter your detailed mission description below.', - 'You can use multiple lines and markdown formatting.', - '', - '## Task:', - '', - '(Type your task here...)', - '', - '## Goals:', - '- ', - '', - '## Notes:', - '- ', - '', - '', - '────────────────────────────────────────', - 'Press to continue to fork options', - 'Press to cancel', - } - - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - - -- Create window - local width = math.min(100, vim.o.columns - 10) - local height = math.min(25, vim.o.lines - 6) - - local win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', - width = width, - height = height, - col = math.floor((vim.o.columns - width) / 2), - row = math.floor((vim.o.lines - height) / 2), - style = 'minimal', - border = 'rounded', - title = ' Agent Mission (Step 1/2) ', - title_pos = 'center', - }) - - -- Set telescope-like highlights - vim.api.nvim_win_set_option(win, 'winhl', 'Normal:TelescopeNormal,FloatBorder:TelescopeBorder,FloatTitle:TelescopeTitle') - - -- Position cursor at task area - vim.api.nvim_win_set_cursor(win, {8, 0}) - - -- Function to extract mission from buffer - local function get_mission() - local all_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local mission_lines = {} - local in_content = false - - for _, line in ipairs(all_lines) do - -- Stop at the separator line - if line:match('^────') then - break - end - - -- Skip header lines but include everything else - if not line:match('^#') and not line:match('^Enter your detailed') and not line:match('^You can use multiple') then - if line ~= '' or in_content then - in_content = true - table.insert(mission_lines, line) - end - end - end - - -- Clean up the mission text - local mission = table.concat(mission_lines, '\n'):gsub('^%s*(.-)%s*$', '%1') - -- Remove placeholder text - mission = mission:gsub('%(Type your task here%.%.%.%)', '') - return mission - end - - -- Function to proceed to fork selection - local function proceed_to_fork_selection() - local mission = get_mission() - if mission == '' or mission:match('^%s*$') then - vim.notify('Please enter a mission description', vim.log.levels.ERROR) - return - end - - -- Close current window - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - end - - -- Proceed to fork selection - local state = { - fork_option = 1, - mission = mission, - } - M.show_fork_options_ui(state) - end - - local function close_window() - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - end - end - - -- Set up keymaps - vim.api.nvim_buf_set_keymap(buf, 'n', '', '', { - callback = proceed_to_fork_selection, - silent = true, - }) - vim.api.nvim_buf_set_keymap(buf, 'i', '', '', { - callback = proceed_to_fork_selection, - silent = true, - }) - vim.api.nvim_buf_set_keymap(buf, 'n', '', '', { - callback = close_window, - silent = true, - }) - - -- Start in insert mode at the task area - vim.cmd('startinsert') -end - --- Show fork options UI with telescope-like styling -function M.show_fork_options_ui(state) - local default_branch = claude.git.default_branch() - local options = { - { label = 'Current branch', desc = 'Fork from your current branch state', value = 1 }, - { label = default_branch .. ' branch', desc = 'Start fresh from ' .. default_branch .. ' branch', value = 2 }, - { label = 'Stash current changes', desc = 'Include your uncommitted changes', value = 3 }, - { label = 'Other branch...', desc = 'Choose any branch to fork from', value = 4 }, - } - - -- Create buffer for options - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_option(buf, 'buftype', 'nofile') - vim.api.nvim_buf_set_option(buf, 'bufhidden', 'wipe') - - -- Create lines for display - local lines = {} - -- Handle multiline mission by showing first line only - local mission_first_line = state.mission:match('[^\n]*') or state.mission - local mission_preview = mission_first_line:sub(1, 60) .. (mission_first_line:len() > 60 and '...' or '') - table.insert(lines, string.format('Mission: %s', mission_preview)) - table.insert(lines, '') - table.insert(lines, 'Select fork option:') - table.insert(lines, '') - - for i, option in ipairs(options) do - local icon = i == state.fork_option and '▶' or ' ' - table.insert(lines, string.format('%s %d. %s', icon, i, option.label)) - table.insert(lines, string.format(' %s', option.desc)) - table.insert(lines, '') - end - - table.insert(lines, 'Press Enter to create agent, q to cancel') - - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.api.nvim_buf_set_option(buf, 'modifiable', false) - - -- Create window with telescope-like styling - local width = math.min(80, vim.o.columns - 10) - local height = math.min(#lines + 4, vim.o.lines - 10) - - local win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', - width = width, - height = height, - col = math.floor((vim.o.columns - width) / 2), - row = math.floor((vim.o.lines - height) / 2), - style = 'minimal', - border = 'rounded', - title = ' Fork Options (Step 2/2) ', - title_pos = 'center', - }) - - -- Set telescope-like highlights - vim.api.nvim_win_set_option(win, 'winhl', 'Normal:TelescopeNormal,FloatBorder:TelescopeBorder,FloatTitle:TelescopeTitle') - - -- Function to update the display - local function update_display() - local updated_lines = {} - -- Handle multiline mission by showing first line only - local mission_first_line = state.mission:match('[^\n]*') or state.mission - local mission_preview = mission_first_line:sub(1, 60) .. (mission_first_line:len() > 60 and '...' or '') - table.insert(updated_lines, string.format('Mission: %s', mission_preview)) - table.insert(updated_lines, '') - table.insert(updated_lines, 'Select fork option:') - table.insert(updated_lines, '') - - for i, option in ipairs(options) do - local icon = i == state.fork_option and '▶' or ' ' - table.insert(updated_lines, string.format('%s %d. %s', icon, i, option.label)) - table.insert(updated_lines, string.format(' %s', option.desc)) - table.insert(updated_lines, '') - end - - table.insert(updated_lines, 'Press Enter to create agent, q to cancel') - - vim.api.nvim_buf_set_option(buf, 'modifiable', true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, updated_lines) - vim.api.nvim_buf_set_option(buf, 'modifiable', false) - end - - -- Create agent with selected options - local function create_agent() - -- Close the window - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - end - - -- Handle fork option - local fork_from = nil - local default_branch = claude.git.default_branch() - if state.fork_option == 1 then - -- Current branch (default) - fork_from = { type = 'branch', branch = claude.git.current_branch() or default_branch } - elseif state.fork_option == 2 then - -- Default branch (main/master) - fork_from = { type = 'branch', branch = default_branch } - elseif state.fork_option == 3 then - -- Stash current changes - fork_from = { type = 'stash' } - elseif state.fork_option == 4 then - -- Other branch - show branch selection - M.show_branch_selection(function(branch) - if branch then - fork_from = { type = 'branch', branch = branch } - M.create_agent_from_task(state.mission, fork_from) - end - end) - return - end - - -- Create the agent - M.create_agent_from_task(state.mission, fork_from) - end - - -- Navigation functions - local function move_up() - if state.fork_option > 1 then - state.fork_option = state.fork_option - 1 - update_display() - end - end - - local function move_down() - if state.fork_option < #options then - state.fork_option = state.fork_option + 1 - update_display() - end - end - - -- Set up keymaps - local function close_window() - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - end - end - - -- Number keys - for i = 1, #options do - vim.api.nvim_buf_set_keymap(buf, 'n', tostring(i), '', { - callback = function() - state.fork_option = i - update_display() - end, - silent = true, - }) - end - - -- Navigation keys - vim.api.nvim_buf_set_keymap(buf, 'n', 'j', '', { callback = move_down, silent = true }) - vim.api.nvim_buf_set_keymap(buf, 'n', 'k', '', { callback = move_up, silent = true }) - vim.api.nvim_buf_set_keymap(buf, 'n', '', '', { callback = move_down, silent = true }) - vim.api.nvim_buf_set_keymap(buf, 'n', '', '', { callback = move_up, silent = true }) - - -- Action keys - vim.api.nvim_buf_set_keymap(buf, 'n', '', '', { callback = create_agent, silent = true }) - vim.api.nvim_buf_set_keymap(buf, 'n', 'q', '', { callback = close_window, silent = true }) - vim.api.nvim_buf_set_keymap(buf, 'n', '', '', { callback = close_window, silent = true }) -end - --- Show branch selection UI -function M.show_branch_selection(callback) - -- Get list of branches - local branches_output = claude.utils.exec('git branch -a') - if not branches_output then - vim.notify('Failed to get branches', vim.log.levels.ERROR) - callback(nil) - return - end - - local branches = {} - for line in branches_output:gmatch('[^\n]+') do - local branch = line:match('^%s*%*?%s*(.+)$') - if branch and not branch:match('HEAD detached') then - -- Clean up remote branch names - branch = branch:gsub('^remotes/origin/', '') - table.insert(branches, branch) - end - end - - -- Remove duplicates - local seen = {} - local unique_branches = {} - for _, branch in ipairs(branches) do - if not seen[branch] then - seen[branch] = true - table.insert(unique_branches, branch) - end - end - - vim.ui.select(unique_branches, { - prompt = 'Select branch to fork from:', - }, callback) -end - --- Create agent from task and fork options -function M.create_agent_from_task(task, fork_from) - if not claude.tmux.validate() then - return - end - - -- Create agent work directory - local project_root = claude.utils.get_project_root() - local work_dir = project_root .. '/' .. claude.config.agents.work_dir - local agent_dir = work_dir .. '/' .. claude.utils.agent_dirname(task) - - -- Ensure work directory exists - if not claude.utils.ensure_dir(work_dir) then - vim.notify('Failed to create work directory', vim.log.levels.ERROR) - return - end - - -- Add to gitignore if needed - if claude.config.agents.auto_gitignore then - claude.git.add_to_gitignore(claude.config.agents.work_dir .. '/') - end - - -- Create agent directory - if not claude.utils.ensure_dir(agent_dir) then - vim.notify('Failed to create agent directory', vim.log.levels.ERROR) - return - end - - -- Handle fork options - local success, result - local base_info = '' - - if fork_from and fork_from.type == 'stash' then - -- Create a stash first - vim.notify('Creating stash of current changes...', vim.log.levels.INFO) - local stash_cmd = 'git stash push -m "Agent fork: ' .. task:sub(1, 50) .. '"' - local stash_result = claude.utils.exec(stash_cmd) - - if stash_result and stash_result:match('Saved working directory') then - -- Create worktree from current branch - local branch = claude.git.current_branch() or claude.git.default_branch() - success, result = claude.git.create_worktree(agent_dir, branch) - - if success then - -- Apply the stash in the new worktree - local apply_cmd = string.format('cd "%s" && git stash pop', agent_dir) - claude.utils.exec(apply_cmd) - base_info = string.format('Forked from: %s (with stashed changes)', branch) - end - else - vim.notify('No changes to stash, using current branch', vim.log.levels.INFO) - fork_from = { type = 'branch', branch = claude.git.current_branch() or claude.git.default_branch() } - end - end - - if not success and fork_from and fork_from.type == 'branch' then - -- Create worktree from specified branch - success, result = claude.git.create_worktree(agent_dir, fork_from.branch) - base_info = string.format('Forked from: %s branch', fork_from.branch) - elseif not success then - -- Default behavior - use current branch - local branch = claude.git.current_branch() or claude.git.default_branch() - success, result = claude.git.create_worktree(agent_dir, branch) - base_info = string.format('Forked from: %s branch', branch) - end - - if not success then - vim.notify('Failed to create worktree: ' .. tostring(result), vim.log.levels.ERROR) - return - end - - -- Create mission log with fork info - local log_content = string.format( - "Agent Mission Log\n================\n\nTask: %s\nStarted: %s\nStatus: Active\n%s\n\n", - task, - os.date('%Y-%m-%d %H:%M:%S'), - base_info - ) - claude.utils.write_file(agent_dir .. '/mission.log', log_content) - - -- Check agent limit - local active_count = claude.registry.get_active_count() - if active_count >= claude.config.agents.max_agents then - vim.notify(string.format( - 'Agent limit reached (%d/%d). Complete or kill existing agents first.', - active_count, - claude.config.agents.max_agents - ), vim.log.levels.ERROR) - return - end - - -- Create tmux window for agent - local window_name = 'claude-' .. claude.utils.timestamp() - local window_id = claude.tmux.create_agent_window(window_name, agent_dir) - - if window_id then - -- Prepare fork info for registry - local fork_info = { - type = fork_from and fork_from.type or 'branch', - branch = fork_from and fork_from.branch or (claude.git.current_branch() or claude.git.default_branch()), - base_info = base_info - } - - -- Register agent with fork info - local agent_id = claude.registry.register(task, agent_dir, window_id, window_name, fork_info) - - -- Update mission log with agent ID - local log_content = claude.utils.read_file(agent_dir .. '/mission.log') - log_content = log_content .. string.format("\nAgent ID: %s\n", agent_id) - claude.utils.write_file(agent_dir .. '/mission.log', log_content) - - -- Create progress file for agent to update - local progress_file = agent_dir .. '/progress.txt' - claude.utils.write_file(progress_file, 'Starting...') - - -- In first pane (0) open Neovim - claude.tmux.send_to_window(window_id, 'nvim .') - - -- Split right side 40% and open Claude - local pane_claude = claude.tmux.split_window(window_id, 'h', 40) - if pane_claude then - claude.tmux.send_to_pane(pane_claude, 'claude --dangerously-skip-permissions') - - -- Send initial task description to Claude - vim.wait(1000) -- Wait for Claude to start - local task_msg = string.format( - "I'm an autonomous agent working on the following task:\n\n%s\n\n" .. - "My workspace is: %s\n" .. - "I should work independently to complete this task.\n\n" .. - "To report progress, update the file: %s/progress.txt\n" .. - "Example: echo 'Analyzing codebase...' > progress.txt\n\n" .. - "%s", - task, agent_dir, agent_dir, base_info - ) - claude.tmux.send_text_to_pane(pane_claude, task_msg) - end - - vim.notify(string.format( - 'Background agent started\nID: %s\nTask: %s\nWorkspace: %s\nWindow: %s\n%s', - agent_id, - task, - agent_dir, - window_name, - base_info - ), vim.log.levels.INFO) - else - vim.notify('Failed to create agent window', vim.log.levels.ERROR) - end -end - --- List all agents with interactive UI -function M.list_agents() - -- Validate agents first - claude.registry.validate_agents() - - local agents = claude.registry.get_project_agents() - if vim.tbl_isempty(agents) then - vim.notify('No agents found for this project', vim.log.levels.INFO) - return - end - - -- Sort agents by start time (newest first) - table.sort(agents, function(a, b) return a.start_time > b.start_time end) - - -- Create interactive buffer - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_option(buf, 'buftype', 'nofile') - - -- Build display lines - local lines = {} - local agent_map = {} -- Map line numbers to agents - - table.insert(lines, ' Claude Agents') - table.insert(lines, '') - table.insert(lines, ' Keys: switch · d diff · k kill · r refresh · q quit') - table.insert(lines, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') - table.insert(lines, '') - - local start_line = #lines + 1 - for i, agent in ipairs(agents) do - local formatted = claude.registry.format_agent(agent) - table.insert(lines, string.format(' %d. %s', i, formatted)) - agent_map[#lines] = agent - - -- Add work directory info - local dir_info = ' ' .. (agent.work_dir:match('([^/]+)/?$') or agent.work_dir) - table.insert(lines, dir_info) - agent_map[#lines] = agent - - table.insert(lines, '') - end - - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.api.nvim_buf_set_option(buf, 'modifiable', false) - - -- Create window - local width = math.max(80, math.min(120, vim.o.columns - 10)) - local height = math.min(#lines + 2, vim.o.lines - 10) - - local win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', - width = width, - height = height, - col = math.floor((vim.o.columns - width) / 2), - row = math.floor((vim.o.lines - height) / 2), - style = 'minimal', - border = 'rounded', - title = ' Agent Manager ', - title_pos = 'center', - }) - - -- Set initial cursor position - vim.api.nvim_win_set_cursor(win, {start_line, 0}) - - -- Helper function to get agent under cursor - local function get_current_agent() - local row = vim.api.nvim_win_get_cursor(win)[1] - return agent_map[row] - end - - -- Keymaps - local function close_window() - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - end - end - - -- Enter - Switch to agent - vim.api.nvim_buf_set_keymap(buf, 'n', '', '', { - callback = function() - local agent = get_current_agent() - if agent then - close_window() - M.switch_agent(agent._registry_id) - end - end, - silent = true, - }) - - -- d - Diff agent - vim.api.nvim_buf_set_keymap(buf, 'n', 'd', '', { - callback = function() - local agent = get_current_agent() - if agent then - close_window() - M.diff_agent(agent._registry_id) - end - end, - silent = true, - }) - - -- k - Kill agent - vim.api.nvim_buf_set_keymap(buf, 'n', 'k', '', { - callback = function() - local agent = get_current_agent() - if agent and agent.status == 'active' then - vim.ui.confirm( - string.format('Kill agent "%s"?', agent.task:match('[^\n]*') or agent.task), - { '&Yes', '&No' }, - function(choice) - if choice == 1 then - M.kill_agent(agent._registry_id) - close_window() - -- Reopen the list to show updated state - vim.defer_fn(function() M.list_agents() end, 100) - end - end - ) - else - vim.notify('Agent is not active', vim.log.levels.INFO) - end - end, - silent = true, - }) - - -- r - Refresh - vim.api.nvim_buf_set_keymap(buf, 'n', 'r', '', { - callback = function() - close_window() - M.list_agents() - end, - silent = true, - }) - - -- Number keys for quick selection - for i = 1, math.min(9, #agents) do - vim.api.nvim_buf_set_keymap(buf, 'n', tostring(i), '', { - callback = function() - close_window() - M.switch_agent(agents[i]._registry_id) - end, - silent = true, - }) - end - - -- Close keymaps - vim.api.nvim_buf_set_keymap(buf, 'n', 'q', '', { callback = close_window, silent = true }) - vim.api.nvim_buf_set_keymap(buf, 'n', '', '', { callback = close_window, silent = true }) - - -- Add highlighting - vim.api.nvim_win_set_option(win, 'winhl', 'Normal:TelescopeNormal,FloatBorder:TelescopeBorder') - vim.api.nvim_win_set_option(win, 'cursorline', true) -end - --- Kill an agent -function M.kill_agent(agent_id) - if not agent_id or agent_id == '' then - -- Show selection UI for agent killing - M.show_kill_agent_ui() - return - end - - local agent = claude.registry.get(agent_id) - if not agent then - vim.notify('Agent not found: ' .. agent_id, vim.log.levels.ERROR) - return - end - - -- Kill tmux window - if agent.window_id then - local cmd = 'tmux kill-window -t ' .. agent.window_id - claude.utils.exec(cmd) - end - - -- Update registry - claude.registry.update_status(agent_id, 'killed') - - vim.notify(string.format('Agent killed: %s (%s)', agent_id, agent.task), vim.log.levels.INFO) -end - --- Show agent kill selection UI -function M.show_kill_agent_ui() - -- Validate agents first - claude.registry.validate_agents() - - local agents = claude.registry.get_project_agents() - local active_agents = {} - - for id, agent in pairs(agents) do - if agent.status == 'active' then - agent.id = id - table.insert(active_agents, agent) - end - end - - if #active_agents == 0 then - vim.notify('No active agents to kill', vim.log.levels.INFO) - return - end - - -- Sort agents by start time - table.sort(active_agents, function(a, b) return a.start_time > b.start_time end) - - -- Track selection state - local selected = {} - for i = 1, #active_agents do - selected[i] = false - end - - -- Create buffer - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_option(buf, 'bufhidden', 'wipe') - - -- Create window - local width = 80 - local height = math.min(#active_agents * 4 + 4, 25) - local opts = { - relative = 'editor', - width = width, - height = height, - col = (vim.o.columns - width) / 2, - row = (vim.o.lines - height) / 2, - style = 'minimal', - border = 'rounded', - title = ' Kill Claude Agents ', - title_pos = 'center', - } - - local win = vim.api.nvim_open_win(buf, true, opts) - vim.api.nvim_win_set_option(win, 'winhl', 'Normal:Normal,FloatBorder:Comment') - - -- Function to update display - local function update_display() - local lines = { 'Kill Claude Agents (Space: toggle, Y: confirm kill, q: quit):', '' } - - for i, agent in ipairs(active_agents) do - local icon = selected[i] and '●' or '○' - local formatted = claude.registry.format_agent(agent) - table.insert(lines, string.format('%s %s', icon, formatted)) - table.insert(lines, ' ID: ' .. agent.id) - table.insert(lines, ' Window: ' .. (agent.window_name or 'unknown')) - table.insert(lines, '') - end - - -- Get current cursor position - local cursor_line = 1 - if win and vim.api.nvim_win_is_valid(win) then - cursor_line = vim.api.nvim_win_get_cursor(win)[1] - end - - -- Update buffer content - vim.api.nvim_buf_set_option(buf, 'modifiable', true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.api.nvim_buf_set_option(buf, 'modifiable', false) - - -- Restore cursor position - if win and vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_set_cursor(win, {math.min(cursor_line, #lines), 0}) - end - end - - -- Initial display - update_display() - - -- Set up keybindings - local function close_window() - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - end - end - - local function get_agent_from_line(line) - if line <= 2 then return nil end -- Header lines - local agent_index = math.ceil((line - 2) / 4) - return agent_index <= #active_agents and agent_index or nil - end - - local function toggle_selection() - local line = vim.api.nvim_win_get_cursor(win)[1] - local agent_index = get_agent_from_line(line) - - if agent_index then - selected[agent_index] = not selected[agent_index] - update_display() - end - end - - local function confirm_kill() - local selected_agents = {} - for i, is_selected in ipairs(selected) do - if is_selected then - table.insert(selected_agents, active_agents[i]) - end - end - - if #selected_agents == 0 then - vim.notify('No agents selected', vim.log.levels.INFO) - return - end - - close_window() - - -- Confirm before killing - local task_list = {} - for _, agent in ipairs(selected_agents) do - table.insert(task_list, '• ' .. agent.task:sub(1, 50)) - end - - vim.ui.select( - {'Yes', 'No'}, - { - prompt = string.format('Kill %d agent(s)?', #selected_agents), - format_item = function(item) - if item == 'Yes' then - return 'Yes - Kill selected agents:\n' .. table.concat(task_list, '\n') - else - return 'No - Cancel' - end - end - }, - function(choice) - if choice == 'Yes' then - for _, agent in ipairs(selected_agents) do - M.kill_agent(agent.id) - end - end - end - ) - end - - -- Close on q or Esc - vim.api.nvim_buf_set_keymap(buf, 'n', 'q', '', { - callback = close_window, - silent = true, - noremap = true, - }) - vim.api.nvim_buf_set_keymap(buf, 'n', '', '', { - callback = close_window, - silent = true, - noremap = true, - }) - - -- Space to toggle selection - vim.api.nvim_buf_set_keymap(buf, 'n', '', '', { - callback = toggle_selection, - silent = true, - noremap = true, - }) - - -- Y to confirm kill - vim.api.nvim_buf_set_keymap(buf, 'n', 'Y', '', { - callback = confirm_kill, - silent = true, - noremap = true, - }) - vim.api.nvim_buf_set_keymap(buf, 'n', 'y', '', { - callback = confirm_kill, - silent = true, - noremap = true, - }) -end - --- Clean up old agents -function M.clean_agents() - -- Show cleanup options - vim.ui.select( - {'Clean completed agents', 'Clean agents older than ' .. claude.config.agents.cleanup_days .. ' days', 'Clean ALL inactive agents', 'Cancel'}, - { - prompt = 'Select cleanup option:', - }, - function(choice) - if not choice or choice == 'Cancel' then - return - end - - local removed = 0 - - if choice == 'Clean completed agents' then - removed = M.cleanup_completed_agents() - elseif choice:match('older than') then - removed = claude.registry.cleanup(claude.config.agents.cleanup_days) - elseif choice == 'Clean ALL inactive agents' then - removed = M.cleanup_all_inactive_agents() - end - - if removed > 0 then - vim.notify(string.format('Cleaned up %d agent(s)', removed), vim.log.levels.INFO) - else - vim.notify('No agents to clean up', vim.log.levels.INFO) - end - end - ) -end - --- Clean up completed agents -function M.cleanup_completed_agents() - local removed = 0 - local agents = claude.registry.get_project_agents() - - for _, agent in ipairs(agents) do - if agent.status == 'completed' then - -- Remove work directory - if agent.work_dir and claude.utils.file_exists(agent.work_dir) then - -- Remove git worktree properly - claude.git.remove_worktree(agent.work_dir) - end - - claude.registry.remove(agent.id) - removed = removed + 1 - end - end - - return removed -end - --- Clean up all inactive agents -function M.cleanup_all_inactive_agents() - local removed = 0 - local agents = claude.registry.get_project_agents() - - for _, agent in ipairs(agents) do - if agent.status ~= 'active' then - -- Remove work directory - if agent.work_dir and claude.utils.file_exists(agent.work_dir) then - -- Remove git worktree properly - claude.git.remove_worktree(agent.work_dir) - end - - claude.registry.remove(agent.id) - removed = removed + 1 - end - end - - return removed -end - --- Clean orphaned agent directories -function M.clean_orphaned_directories() - local work_dir = claude.utils.get_project_root() .. '/' .. claude.config.agents.work_dir - - if not claude.utils.file_exists(work_dir) then - vim.notify('No agent work directory found', vim.log.levels.INFO) - return - end - - -- Get all directories in agent work dir - local dirs = vim.fn.readdir(work_dir) - local orphaned = {} - - -- Check each directory - for _, dir in ipairs(dirs) do - local dir_path = work_dir .. '/' .. dir - local found = false - - -- Check if this directory is tracked by any agent - local agents = claude.registry.get_project_agents() - for _, agent in ipairs(agents) do - if agent.work_dir == dir_path then - found = true - break - end - end - - if not found then - table.insert(orphaned, dir_path) - end - end - - if #orphaned == 0 then - vim.notify('No orphaned directories found', vim.log.levels.INFO) - return - end - - -- Confirm cleanup - vim.ui.select( - {'Yes - Remove ' .. #orphaned .. ' orphaned directories', 'No - Cancel'}, - { - prompt = 'Found ' .. #orphaned .. ' orphaned agent directories. Clean them up?', - }, - function(choice) - if choice and choice:match('^Yes') then - local removed = 0 - for _, dir in ipairs(orphaned) do - claude.git.remove_worktree(dir) - removed = removed + 1 - end - vim.notify(string.format('Removed %d orphaned directories', removed), vim.log.levels.INFO) - end - end - ) -end - --- Debug pane detection -function M.debug_panes() - local utils = require('nvim-claude.utils') - - -- Get all panes with details - local cmd = "tmux list-panes -F '#{pane_id}:#{pane_pid}:#{pane_title}:#{pane_current_command}'" - local result = utils.exec(cmd) - - local lines = { 'Claude Pane Debug Info:', '' } - - if result and result ~= '' then - table.insert(lines, 'All panes:') - for line in result:gmatch('[^\n]+') do - local pane_id, pane_pid, pane_title, pane_cmd = line:match('^([^:]+):([^:]+):([^:]*):(.*)$') - if pane_id and pane_pid then - table.insert(lines, string.format(' %s: pid=%s, title="%s", cmd="%s"', - pane_id, pane_pid, pane_title or '', pane_cmd or '')) - end - end - else - table.insert(lines, 'No panes found') - end - - table.insert(lines, '') - - -- Show detected Claude pane - local detected_pane = claude.tmux.find_claude_pane() - if detected_pane then - table.insert(lines, 'Detected Claude pane: ' .. detected_pane) - else - table.insert(lines, 'No Claude pane detected') - end - - -- Display in floating window - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.api.nvim_buf_set_option(buf, 'modifiable', false) - - local width = 80 - local height = math.min(#lines + 2, 25) - local opts = { - relative = 'editor', - width = width, - height = height, - col = (vim.o.columns - width) / 2, - row = (vim.o.lines - height) / 2, - style = 'minimal', - border = 'rounded', - } - - local win = vim.api.nvim_open_win(buf, true, opts) - vim.api.nvim_win_set_option(win, 'winhl', 'Normal:Normal,FloatBorder:Comment') - - -- Close on q or Esc - vim.api.nvim_buf_set_keymap(buf, 'n', 'q', ':close', { silent = true }) - vim.api.nvim_buf_set_keymap(buf, 'n', '', ':close', { silent = true }) -end - --- Switch to agent's worktree -function M.switch_agent(agent_id) - -- Get agent from registry - local agent = claude.registry.get(agent_id) - if not agent then - -- If no ID provided, show selection UI - local agents = claude.registry.get_project_agents() - if #agents == 0 then - vim.notify('No agents found', vim.log.levels.INFO) - return - end - - -- Always show selection UI unless ID was provided - M.show_agent_selection('switch') - return - end - - -- Check if agent is still active - if not claude.registry.check_window_exists(agent.window_id) then - vim.notify('Agent window no longer exists', vim.log.levels.ERROR) - return - end - - -- Check if worktree still exists - if not claude.utils.file_exists(agent.work_dir) then - vim.notify(string.format('Agent work directory no longer exists: %s', agent.work_dir), vim.log.levels.ERROR) - -- Try to check if it's a directory issue - local parent_dir = vim.fn.fnamemodify(agent.work_dir, ':h') - if claude.utils.file_exists(parent_dir) then - vim.notify('Parent directory exists: ' .. parent_dir, vim.log.levels.INFO) - -- List contents to debug - local contents = vim.fn.readdir(parent_dir) - if contents and #contents > 0 then - vim.notify('Contents: ' .. table.concat(contents, ', '), vim.log.levels.INFO) - end - end - return - end - - -- Save current session state - vim.cmd('wall') -- Write all buffers - - -- Change to agent's worktree - vim.cmd('cd ' .. agent.work_dir) - - -- Background agents keep hooks disabled - no inline diffs - -- This keeps the workflow simple and predictable - - -- Clear all buffers and open new nvim in the worktree - vim.cmd('%bdelete') - vim.cmd('edit .') - - -- Notify user - vim.notify(string.format( - 'Switched to agent worktree (no inline diffs)\nTask: %s\nWindow: %s\nUse :ClaudeDiffAgent to review changes', - agent.task, - agent.window_name - ), vim.log.levels.INFO) - - -- Also switch tmux to the agent's window - local tmux_cmd = string.format('tmux select-window -t %s', agent.window_id) - os.execute(tmux_cmd) -end - --- Show agent selection UI for different actions -function M.show_agent_selection(action) - local agents = claude.registry.get_project_agents() - - if #agents == 0 then - vim.notify('No active agents found', vim.log.levels.INFO) - return - end - - -- Create selection buffer - local buf = vim.api.nvim_create_buf(false, true) - local lines = { 'Select agent to ' .. action .. ':', '', } - - for i, agent in ipairs(agents) do - local line = string.format('%d. %s', i, claude.registry.format_agent(agent)) - table.insert(lines, line) - end - - table.insert(lines, '') - table.insert(lines, 'Press number to select, q to quit') - - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.api.nvim_buf_set_option(buf, 'modifiable', false) - vim.api.nvim_buf_set_option(buf, 'buftype', 'nofile') - - -- Create floating window - local width = math.max(60, math.min(100, vim.o.columns - 10)) - local height = math.min(#lines + 2, vim.o.lines - 10) - - local win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', - width = width, - height = height, - col = math.floor((vim.o.columns - width) / 2), - row = math.floor((vim.o.lines - height) / 2), - style = 'minimal', - border = 'rounded', - title = ' Agent Selection ', - title_pos = 'center', - }) - - -- Set up keymaps for selection - for i = 1, math.min(9, #agents) do - vim.api.nvim_buf_set_keymap(buf, 'n', tostring(i), '', { - silent = true, - callback = function() - vim.cmd('close') -- Close selection window - local selected_agent = agents[i] - if selected_agent then - if action == 'switch' then - M.switch_agent(selected_agent.id) - elseif action == 'diff' then - M.diff_agent(selected_agent.id) - end - end - end - }) - end - - vim.api.nvim_buf_set_keymap(buf, 'n', 'q', ':close', { silent = true }) - vim.api.nvim_buf_set_keymap(buf, 'n', '', ':close', { silent = true }) -end - - --- Review agent changes with diffview -function M.diff_agent(agent_id) - -- Get agent from registry - local agent = claude.registry.get(agent_id) - if not agent then - -- Check if we're currently in an agent worktree - local current_dir = vim.fn.getcwd() - local agents = claude.registry.get_project_agents() - - -- Try to find agent by work directory - for _, a in ipairs(agents) do - if a.work_dir == current_dir then - agent = a - break - end - end - - if not agent then - -- If no ID provided and not in agent dir, show selection UI - if #agents == 0 then - vim.notify('No agents found', vim.log.levels.INFO) - return - elseif #agents == 1 then - -- Auto-select the only agent - agent = agents[1] - else - -- Show selection UI for multiple agents - M.show_agent_selection('diff') - return - end - end - end - - -- Check if worktree still exists - if not claude.utils.file_exists(agent.work_dir) then - vim.notify('Agent work directory no longer exists', vim.log.levels.ERROR) - return - end - - -- Get the base branch that the agent started from - local base_branch = agent.fork_info and agent.fork_info.branch or (claude.git.current_branch() or claude.git.default_branch()) - - -- Check if diffview is available - local has_diffview = pcall(require, 'diffview') - if not has_diffview then - -- Fallback to fugitive - vim.notify('Diffview not found, using fugitive', vim.log.levels.INFO) - -- Save current directory and change to agent directory - local original_cwd = vim.fn.getcwd() - vim.cmd('cd ' .. agent.work_dir) - vim.cmd('Git diff ' .. base_branch) - else - -- Save current directory to restore later - local original_cwd = vim.fn.getcwd() - - -- Change to the agent's worktree directory - vim.cmd('cd ' .. agent.work_dir) - - -- Set up autocmd to restore directory when diffview closes - local restore_dir_group = vim.api.nvim_create_augroup('ClaudeRestoreDir', { clear = true }) - vim.api.nvim_create_autocmd('User', { - pattern = 'DiffviewViewClosed', - group = restore_dir_group, - once = true, - callback = function() - vim.cmd('cd ' .. original_cwd) - vim.notify('Restored to original directory: ' .. original_cwd, vim.log.levels.DEBUG) - end - }) - - -- Also restore on BufWinLeave as a fallback - vim.api.nvim_create_autocmd('BufWinLeave', { - pattern = 'diffview://*', - group = restore_dir_group, - once = true, - callback = function() - -- Small delay to ensure diffview cleanup is complete - vim.defer_fn(function() - if vim.fn.getcwd() ~= original_cwd then - vim.cmd('cd ' .. original_cwd) - vim.notify('Restored to original directory: ' .. original_cwd, vim.log.levels.DEBUG) - end - end, 100) - end - }) - - -- Now diffview will work in the context of the worktree - -- First check if there are uncommitted changes - local git_status = claude.git.status(agent.work_dir) - local has_uncommitted = #git_status > 0 - - if has_uncommitted then - -- Show working directory changes (including uncommitted files) - vim.cmd('DiffviewOpen') - vim.notify(string.format( - 'Showing uncommitted changes in agent worktree\nTask: %s\nWorktree: %s\n\nNote: Agent has uncommitted changes. To see branch diff, commit the changes first.', - agent.task:match('[^\n]*') or agent.task, - agent.work_dir - ), vim.log.levels.INFO) - else - -- Use triple-dot notation to compare against the merge-base - local cmd = string.format(':DiffviewOpen %s...HEAD --imply-local', base_branch) - vim.cmd(cmd) - vim.notify(string.format( - 'Reviewing agent changes\nTask: %s\nComparing against: %s\nWorktree: %s\nOriginal dir: %s', - agent.task:match('[^\n]*') or agent.task, - base_branch, - agent.work_dir, - original_cwd - ), vim.log.levels.INFO) - end - end -end - -return M \ No newline at end of file diff --git a/lua/nvim-claude/diff-review.lua b/lua/nvim-claude/diff-review.lua deleted file mode 100644 index 6981e4d9..00000000 --- a/lua/nvim-claude/diff-review.lua +++ /dev/null @@ -1,853 +0,0 @@ --- Diff review system for nvim-claude using diffview.nvim -local M = {} - --- State tracking - make it persistent across hook calls -M.current_review = M.current_review or nil - -function M.setup() - -- Set up keybindings - M.setup_keybindings() - - vim.notify('Diff review system loaded (using diffview.nvim)', vim.log.levels.DEBUG) -end - --- Handle Claude edit completion -function M.handle_claude_edit(stash_ref, pre_edit_ref) - if not stash_ref then - vim.notify('No stash reference provided for diff review', vim.log.levels.ERROR) - return - end - - vim.notify('Processing Claude edit with stash: ' .. stash_ref, vim.log.levels.INFO) - - -- Get list of changed files - local changed_files = M.get_changed_files(stash_ref) - if not changed_files or #changed_files == 0 then - vim.notify('No changes detected from Claude edit', vim.log.levels.INFO) - return - end - - -- Initialize review session - M.current_review = { - stash_ref = stash_ref, - pre_edit_ref = pre_edit_ref, -- Store the pre-edit commit reference - timestamp = os.time(), - changed_files = changed_files, - } - - -- Notify user about changes - vim.notify(string.format('Claude made changes to %d file(s): %s', #changed_files, table.concat(changed_files, ', ')), vim.log.levels.INFO) - - vim.notify('Use dd to open diffview, df for fugitive, dc to clear review', vim.log.levels.INFO) - - -- Automatically open diffview - M.open_diffview() -end - --- Handle Claude stashes (only show Claude changes) -function M.handle_claude_stashes(baseline_ref) - if not baseline_ref then - vim.notify('No baseline reference provided for Claude stashes', vim.log.levels.ERROR) - return - end - - vim.notify('Showing Claude stashes against baseline: ' .. baseline_ref, vim.log.levels.INFO) - - -- Get Claude stashes - local claude_stashes = M.get_claude_stashes() - if not claude_stashes or #claude_stashes == 0 then - vim.notify('No Claude stashes found', vim.log.levels.INFO) - return - end - - -- Initialize review session for Claude stashes - M.current_review = { - baseline_ref = baseline_ref, - timestamp = os.time(), - claude_stashes = claude_stashes, - current_stash_index = 0, -- Show cumulative view by default - is_stash_based = true, - } - - -- Notify user about changes - vim.notify(string.format('Found %d Claude stash(es). Use dd for cumulative view, dh to browse.', #claude_stashes), vim.log.levels.INFO) - - -- Automatically open cumulative stash view - M.open_cumulative_stash_view() -end - --- Handle cumulative diff (always show against baseline) - legacy support -function M.handle_cumulative_diff(baseline_ref) - -- Redirect to new stash-based handler - M.handle_claude_stashes(baseline_ref) -end - --- Get list of files changed in the stash -function M.get_changed_files(stash_ref) - local utils = require 'nvim-claude.utils' - local cmd = string.format('git stash show %s --name-only', stash_ref) - local result = utils.exec(cmd) - - if not result or result == '' then - return {} - end - - local files = {} - for line in result:gmatch '[^\n]+' do - if line ~= '' then - table.insert(files, line) - end - end - return files -end - --- Get list of files changed since baseline -function M.get_changed_files_since_baseline(baseline_ref) - local utils = require 'nvim-claude.utils' - local cmd = string.format('git diff --name-only %s', baseline_ref) - local result = utils.exec(cmd) - - if not result or result == '' then - return {} - end - - local files = {} - for line in result:gmatch '[^\n]+' do - if line ~= '' then - table.insert(files, line) - end - end - return files -end - --- Get Claude stashes (only stashes with [claude-edit] messages) -function M.get_claude_stashes() - local utils = require 'nvim-claude.utils' - local cmd = 'git stash list' - local result = utils.exec(cmd) - - if not result or result == '' then - return {} - end - - local stashes = {} - for line in result:gmatch '[^\n]+' do - if line ~= '' and line:match '%[claude%-edit%]' then - local stash_ref = line:match '^(stash@{%d+})' - if stash_ref then - table.insert(stashes, { - ref = stash_ref, - message = line:match ': (.+)$' or line, - }) - end - end - end - return stashes -end - --- Set up keybindings for diff review -function M.setup_keybindings() - -- Review actions - vim.keymap.set('n', 'dd', M.open_diffview, { desc = 'Open Claude diff in diffview' }) - vim.keymap.set('n', 'df', M.open_fugitive, { desc = 'Open Claude diff in fugitive' }) - vim.keymap.set('n', 'dc', M.clear_review, { desc = 'Clear Claude review session' }) - vim.keymap.set('n', 'dl', M.list_changes, { desc = 'List Claude changed files' }) - vim.keymap.set('n', 'da', M.accept_changes, { desc = 'Accept all Claude changes' }) - vim.keymap.set('n', 'dr', M.decline_changes, { desc = 'Decline all Claude changes' }) - - -- Stash browsing - vim.keymap.set('n', 'dh', M.browse_claude_stashes, { desc = 'Browse Claude stash history' }) - vim.keymap.set('n', 'dp', M.previous_stash, { desc = 'View previous Claude stash' }) - vim.keymap.set('n', 'dn', M.next_stash, { desc = 'View next Claude stash' }) - - -- Unified view - vim.keymap.set('n', 'du', M.open_unified_view, { desc = 'Open Claude diff in unified view' }) - - -- Hunk operations - vim.keymap.set('n', 'dka', M.accept_hunk_at_cursor, { desc = 'Accept Claude hunk at cursor' }) - vim.keymap.set('n', 'dkr', M.reject_hunk_at_cursor, { desc = 'Reject Claude hunk at cursor' }) -end - --- Open diffview for current review -function M.open_diffview() - if not M.current_review then - -- Try to recover stash-based session from baseline - local utils = require 'nvim-claude.utils' - local baseline_ref = utils.read_file '/tmp/claude-baseline-commit' - - -- If no baseline file, but we have Claude stashes, use HEAD as baseline - local claude_stashes = M.get_claude_stashes() - if claude_stashes and #claude_stashes > 0 then - if not baseline_ref or baseline_ref == '' then - baseline_ref = 'HEAD' - vim.notify('No baseline found, using HEAD as baseline', vim.log.levels.INFO) - else - baseline_ref = baseline_ref:gsub('%s+', '') - end - - M.current_review = { - baseline_ref = baseline_ref, - timestamp = os.time(), - claude_stashes = claude_stashes, - current_stash_index = 0, -- Show cumulative view by default - is_stash_based = true, - } - vim.notify(string.format('Recovered Claude stash session with %d stashes', #claude_stashes), vim.log.levels.INFO) - end - - if not M.current_review then - vim.notify('No active review session', vim.log.levels.INFO) - return - end - end - - -- Use stash-based diff if available - if M.current_review.is_stash_based then - M.open_cumulative_stash_view() - return - end - - -- Legacy: Use cumulative diff if available - if M.current_review.is_cumulative then - M.open_cumulative_diffview() - return - end - - -- Check if diffview is available - local ok, diffview = pcall(require, 'diffview') - if not ok then - vim.notify('diffview.nvim not available, falling back to fugitive', vim.log.levels.WARN) - M.open_fugitive() - return - end - - -- Use the pre-edit reference if available - if M.current_review.pre_edit_ref then - local cmd = 'DiffviewOpen ' .. M.current_review.pre_edit_ref - vim.notify('Opening diffview with pre-edit commit: ' .. cmd, vim.log.levels.INFO) - vim.cmd(cmd) - else - -- Fallback to comparing stash with its parent - vim.notify('No pre-edit commit found, falling back to stash comparison', vim.log.levels.WARN) - local cmd = string.format('DiffviewOpen %s^..%s', M.current_review.stash_ref, M.current_review.stash_ref) - vim.notify('Opening diffview: ' .. cmd, vim.log.levels.INFO) - vim.cmd(cmd) - end -end - --- Open cumulative stash view (shows all Claude changes since baseline) -function M.open_cumulative_stash_view() - if not M.current_review then - vim.notify('No active review session', vim.log.levels.INFO) - return - end - - -- Check if diffview is available - local ok, diffview = pcall(require, 'diffview') - if not ok then - vim.notify('diffview.nvim not available', vim.log.levels.WARN) - return - end - - if M.current_review.is_stash_based and M.current_review.claude_stashes then - -- Show cumulative diff of all Claude stashes against baseline - local cmd = 'DiffviewOpen ' .. M.current_review.baseline_ref - vim.notify('Opening cumulative Claude stash view: ' .. cmd, vim.log.levels.INFO) - vim.cmd(cmd) - else - -- Fallback to old behavior - local cmd = 'DiffviewOpen ' .. M.current_review.baseline_ref - vim.notify('Opening cumulative diffview: ' .. cmd, vim.log.levels.INFO) - vim.cmd(cmd) - end -end - --- Open cumulative diffview (always against baseline) - legacy support -function M.open_cumulative_diffview() - M.open_cumulative_stash_view() -end - --- Open fugitive diff (fallback) -function M.open_fugitive() - if not M.current_review then - vim.notify('No active review session', vim.log.levels.INFO) - return - end - - -- Use fugitive to show diff - local cmd = 'Gdiffsplit ' .. M.current_review.stash_ref - vim.notify('Opening fugitive: ' .. cmd, vim.log.levels.INFO) - vim.cmd(cmd) -end - --- List changed files -function M.list_changes() - if not M.current_review then - vim.notify('No active review session', vim.log.levels.INFO) - return - end - - local files = M.current_review.changed_files - if #files == 0 then - vim.notify('No changes found', vim.log.levels.INFO) - return - end - - -- Create a telescope picker if available, otherwise just notify - local ok, telescope = pcall(require, 'telescope.pickers') - if ok then - M.telescope_changed_files() - else - vim.notify('Changed files:', vim.log.levels.INFO) - for i, file in ipairs(files) do - vim.notify(string.format(' %d. %s', i, file), vim.log.levels.INFO) - end - end -end - --- Telescope picker for changed files -function M.telescope_changed_files() - local pickers = require 'telescope.pickers' - local finders = require 'telescope.finders' - local conf = require('telescope.config').values - - pickers - .new({}, { - prompt_title = 'Claude Changed Files', - finder = finders.new_table { - results = M.current_review.changed_files, - }, - sorter = conf.generic_sorter {}, - attach_mappings = function(_, map) - map('i', '', function(prompt_bufnr) - local selection = require('telescope.actions.state').get_selected_entry() - require('telescope.actions').close(prompt_bufnr) - vim.cmd('edit ' .. selection[1]) - M.open_diffview() - end) - return true - end, - }) - :find() -end - --- Clear review session -function M.clear_review() - if M.current_review then - M.current_review = nil - - -- Close diffview if it's open - pcall(function() - vim.cmd 'DiffviewClose' - end) - - vim.notify('Claude review session cleared', vim.log.levels.INFO) - else - vim.notify('No active Claude review session', vim.log.levels.INFO) - end -end - --- Accept all Claude changes (update baseline) -function M.accept_changes() - local utils = require 'nvim-claude.utils' - - -- Get project root - local git_root = utils.get_project_root() - if not git_root then - vim.notify('Not in a git repository', vim.log.levels.ERROR) - return - end - - -- Create new baseline commit with current state - local timestamp = os.time() - local commit_msg = string.format('claude-baseline-%d', timestamp) - - -- Stage all changes - local add_cmd = string.format('cd "%s" && git add -A', git_root) - local add_result, add_err = utils.exec(add_cmd) - - if add_err then - vim.notify('Failed to stage changes: ' .. add_err, vim.log.levels.ERROR) - return - end - - -- Create new baseline commit - local commit_cmd = string.format('cd "%s" && git commit -m "%s" --allow-empty', git_root, commit_msg) - local commit_result, commit_err = utils.exec(commit_cmd) - - if commit_err and not commit_err:match 'nothing to commit' then - vim.notify('Failed to create new baseline: ' .. commit_err, vim.log.levels.ERROR) - return - end - - -- Update baseline reference - local baseline_file = '/tmp/claude-baseline-commit' - utils.write_file(baseline_file, 'HEAD') - - -- Clear review session - M.current_review = nil - - -- Close diffview - pcall(function() - vim.cmd 'DiffviewClose' - end) - - vim.notify('All Claude changes accepted! New baseline created.', vim.log.levels.INFO) -end - --- Decline all Claude changes (reset to baseline) -function M.decline_changes() - local utils = require 'nvim-claude.utils' - - -- Get baseline commit - local baseline_file = '/tmp/claude-baseline-commit' - local baseline_ref = utils.read_file(baseline_file) - - if not baseline_ref or baseline_ref == '' then - vim.notify('No baseline commit found', vim.log.levels.ERROR) - return - end - - baseline_ref = baseline_ref:gsub('%s+', '') - - -- Get project root - local git_root = utils.get_project_root() - if not git_root then - vim.notify('Not in a git repository', vim.log.levels.ERROR) - return - end - - -- Reset to baseline (hard reset) - local reset_cmd = string.format('cd "%s" && git reset --hard %s', git_root, baseline_ref) - local reset_result, reset_err = utils.exec(reset_cmd) - - if reset_err then - vim.notify('Failed to reset to baseline: ' .. reset_err, vim.log.levels.ERROR) - return - end - - -- Clear review session - M.current_review = nil - - -- Close diffview - pcall(function() - vim.cmd 'DiffviewClose' - end) - - -- Refresh buffers - vim.cmd 'checktime' - - vim.notify('All Claude changes declined! Reset to baseline.', vim.log.levels.INFO) -end - --- Browse Claude stashes (show list) -function M.browse_claude_stashes() - if not M.current_review or not M.current_review.is_stash_based then - vim.notify('No Claude stash session active', vim.log.levels.INFO) - return - end - - local stashes = M.current_review.claude_stashes - if not stashes or #stashes == 0 then - vim.notify('No Claude stashes found', vim.log.levels.INFO) - return - end - - -- Create a telescope picker if available, otherwise just notify - local ok, telescope = pcall(require, 'telescope.pickers') - if ok then - M.telescope_claude_stashes() - else - vim.notify('Claude stashes:', vim.log.levels.INFO) - for i, stash in ipairs(stashes) do - local marker = (i == M.current_review.current_stash_index) and ' [current]' or '' - vim.notify(string.format(' %d. %s%s', i, stash.message, marker), vim.log.levels.INFO) - end - end -end - --- View previous Claude stash -function M.previous_stash() - if not M.current_review or not M.current_review.is_stash_based then - vim.notify('No Claude stash session active', vim.log.levels.INFO) - return - end - - local stashes = M.current_review.claude_stashes - if not stashes or #stashes == 0 then - vim.notify('No Claude stashes found', vim.log.levels.INFO) - return - end - - local current_index = M.current_review.current_stash_index or 0 - if current_index <= 1 then - vim.notify('Already at first stash', vim.log.levels.INFO) - return - end - - M.current_review.current_stash_index = current_index - 1 - M.view_specific_stash(M.current_review.current_stash_index) -end - --- View next Claude stash -function M.next_stash() - if not M.current_review or not M.current_review.is_stash_based then - vim.notify('No Claude stash session active', vim.log.levels.INFO) - return - end - - local stashes = M.current_review.claude_stashes - if not stashes or #stashes == 0 then - vim.notify('No Claude stashes found', vim.log.levels.INFO) - return - end - - local current_index = M.current_review.current_stash_index or 0 - if current_index >= #stashes then - vim.notify('Already at last stash', vim.log.levels.INFO) - return - end - - M.current_review.current_stash_index = current_index + 1 - M.view_specific_stash(M.current_review.current_stash_index) -end - --- View a specific stash by index -function M.view_specific_stash(index) - if not M.current_review or not M.current_review.is_stash_based then - vim.notify('No Claude stash session active', vim.log.levels.INFO) - return - end - - local stashes = M.current_review.claude_stashes - if not stashes or index < 1 or index > #stashes then - vim.notify('Invalid stash index', vim.log.levels.ERROR) - return - end - - local stash = stashes[index] - - -- Check if diffview is available - local ok, diffview = pcall(require, 'diffview') - if not ok then - vim.notify('diffview.nvim not available', vim.log.levels.WARN) - return - end - - -- Open diffview for this specific stash - local cmd = string.format('DiffviewOpen %s^..%s', stash.ref, stash.ref) - vim.notify(string.format('Opening stash %d: %s', index, stash.message), vim.log.levels.INFO) - vim.cmd(cmd) -end - --- Telescope picker for Claude stashes -function M.telescope_claude_stashes() - local pickers = require 'telescope.pickers' - local finders = require 'telescope.finders' - local conf = require('telescope.config').values - - local stashes = M.current_review.claude_stashes - local stash_entries = {} - - for i, stash in ipairs(stashes) do - table.insert(stash_entries, { - value = i, - display = string.format('%d. %s', i, stash.message), - ordinal = stash.message, - }) - end - - pickers - .new({}, { - prompt_title = 'Claude Stash History', - finder = finders.new_table { - results = stash_entries, - entry_maker = function(entry) - return { - value = entry.value, - display = entry.display, - ordinal = entry.ordinal, - } - end, - }, - sorter = conf.generic_sorter {}, - attach_mappings = function(_, map) - map('i', '', function(prompt_bufnr) - local selection = require('telescope.actions.state').get_selected_entry() - require('telescope.actions').close(prompt_bufnr) - M.current_review.current_stash_index = selection.value - M.view_specific_stash(selection.value) - end) - return true - end, - }) - :find() -end - --- Generate combined patch from all Claude stashes -function M.generate_claude_patch() - if not M.current_review or not M.current_review.is_stash_based then - vim.notify('No Claude stash session active', vim.log.levels.ERROR) - return nil - end - - local utils = require 'nvim-claude.utils' - local baseline_ref = M.current_review.baseline_ref - - -- Generate diff from baseline to current working directory - local cmd = string.format('git diff %s', baseline_ref) - local patch, err = utils.exec(cmd) - - if err then - vim.notify('Failed to generate patch: ' .. err, vim.log.levels.ERROR) - return nil - end - - return patch -end - --- Open unified view for Claude changes -function M.open_unified_view() - if not M.current_review then - -- Try to recover stash-based session from baseline - local utils = require 'nvim-claude.utils' - local baseline_ref = utils.read_file '/tmp/claude-baseline-commit' - - -- If no baseline file, but we have Claude stashes, use HEAD as baseline - local claude_stashes = M.get_claude_stashes() - if claude_stashes and #claude_stashes > 0 then - if not baseline_ref or baseline_ref == '' then - baseline_ref = 'HEAD' - vim.notify('No baseline found, using HEAD as baseline', vim.log.levels.INFO) - else - baseline_ref = baseline_ref:gsub('%s+', '') - end - - M.current_review = { - baseline_ref = baseline_ref, - timestamp = os.time(), - claude_stashes = claude_stashes, - current_stash_index = 0, - is_stash_based = true, - } - vim.notify(string.format('Recovered Claude stash session with %d stashes', #claude_stashes), vim.log.levels.INFO) - end - - if not M.current_review then - vim.notify('No active review session', vim.log.levels.INFO) - return - end - end - - -- Check if unified.nvim is available and load it - local ok, unified = pcall(require, 'unified') - if not ok then - vim.notify('unified.nvim not available, falling back to diffview', vim.log.levels.WARN) - M.open_diffview() - return - end - - -- Ensure unified.nvim is set up - pcall(unified.setup, {}) - - -- Use unified.nvim to show diff against baseline - local baseline_ref = M.current_review.baseline_ref - - -- Try the command with pcall to catch errors - local cmd_ok, cmd_err = pcall(function() - vim.cmd('Unified ' .. baseline_ref) - end) - - if not cmd_ok then - vim.notify('Unified command failed: ' .. tostring(cmd_err) .. ', falling back to diffview', vim.log.levels.WARN) - M.open_diffview() - return - end - - vim.notify('Claude unified diff opened. Use ]h/[h to navigate hunks', vim.log.levels.INFO) -end - --- Accept hunk at cursor position -function M.accept_hunk_at_cursor() - -- Get current buffer and check if we're in a diff view - local bufname = vim.api.nvim_buf_get_name(0) - local filetype = vim.bo.filetype - - -- Check for various diff view types - local is_diff_view = bufname:match 'diffview://' or bufname:match 'Claude Unified Diff' or filetype == 'diff' or filetype == 'git' - - if not is_diff_view then - vim.notify('This command only works in diff views', vim.log.levels.WARN) - return - end - - -- Get current file and line from cursor position - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) - - -- Parse diff to find current hunk - local hunk_info = M.find_hunk_at_line(lines, cursor_line) - if not hunk_info then - vim.notify('No hunk found at cursor position', vim.log.levels.WARN) - return - end - - -- Apply the hunk - M.apply_hunk(hunk_info) -end - --- Reject hunk at cursor position -function M.reject_hunk_at_cursor() - -- Get current buffer and check if we're in a diff view - local bufname = vim.api.nvim_buf_get_name(0) - local filetype = vim.bo.filetype - - -- Check for various diff view types - local is_diff_view = bufname:match 'diffview://' or bufname:match 'Claude Unified Diff' or filetype == 'diff' or filetype == 'git' - - if not is_diff_view then - vim.notify('This command only works in diff views', vim.log.levels.WARN) - return - end - - -- Get current file and line from cursor position - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) - - -- Parse diff to find current hunk - local hunk_info = M.find_hunk_at_line(lines, cursor_line) - if not hunk_info then - vim.notify('No hunk found at cursor position', vim.log.levels.WARN) - return - end - - vim.notify( - string.format('Rejected hunk in %s at lines %d-%d', hunk_info.file, hunk_info.old_start, hunk_info.old_start + hunk_info.old_count - 1), - vim.log.levels.INFO - ) -end - --- Find hunk information at given line in diff buffer -function M.find_hunk_at_line(lines, target_line) - local current_file = nil - local in_hunk = false - local hunk_start_line = nil - local hunk_lines = {} - - for i, line in ipairs(lines) do - -- File header - if line:match '^diff %-%-git' or line:match '^diff %-%-cc' then - current_file = line:match 'b/(.+)$' - elseif line:match '^%+%+%+ b/(.+)' then - current_file = line:match '^%+%+%+ b/(.+)' - end - - -- Hunk header - if line:match '^@@' then - -- If we were in a hunk that included target line, return it - if in_hunk and hunk_start_line and target_line >= hunk_start_line and target_line < i then - return M.parse_hunk_info(hunk_lines, current_file, hunk_start_line) - end - - -- Start new hunk - in_hunk = true - hunk_start_line = i - hunk_lines = { line } - elseif in_hunk then - -- Collect hunk lines - if line:match '^[%+%-%s]' then - table.insert(hunk_lines, line) - else - -- End of hunk - if hunk_start_line and target_line >= hunk_start_line and target_line < i then - return M.parse_hunk_info(hunk_lines, current_file, hunk_start_line) - end - in_hunk = false - end - end - end - - -- Check last hunk - if in_hunk and hunk_start_line and target_line >= hunk_start_line then - return M.parse_hunk_info(hunk_lines, current_file, hunk_start_line) - end - - return nil -end - --- Parse hunk information from diff lines -function M.parse_hunk_info(hunk_lines, file, start_line) - if #hunk_lines == 0 then - return nil - end - - local header = hunk_lines[1] - local old_start, old_count, new_start, new_count = header:match '^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@' - - if not old_start then - return nil - end - - return { - file = file, - old_start = tonumber(old_start), - old_count = tonumber(old_count) or 1, - new_start = tonumber(new_start), - new_count = tonumber(new_count) or 1, - lines = hunk_lines, - buffer_start_line = start_line, - } -end - --- Apply a specific hunk to the working directory -function M.apply_hunk(hunk_info) - local utils = require 'nvim-claude.utils' - - -- Create a patch with just this hunk - local patch_lines = { - 'diff --git a/' .. hunk_info.file .. ' b/' .. hunk_info.file, - 'index 0000000..0000000 100644', - '--- a/' .. hunk_info.file, - '+++ b/' .. hunk_info.file, - } - - -- Add hunk lines - for _, line in ipairs(hunk_info.lines) do - table.insert(patch_lines, line) - end - - local patch_content = table.concat(patch_lines, '\n') - - -- Write patch to temp file - local temp_patch = '/tmp/claude-hunk-patch.diff' - utils.write_file(temp_patch, patch_content) - - -- Apply the patch - local git_root = utils.get_project_root() - if not git_root then - vim.notify('Not in a git repository', vim.log.levels.ERROR) - return - end - - local cmd = string.format('cd "%s" && git apply --cached "%s"', git_root, temp_patch) - local result, err = utils.exec(cmd) - - if err then - -- Try without --cached - cmd = string.format('cd "%s" && git apply "%s"', git_root, temp_patch) - result, err = utils.exec(cmd) - - if err then - vim.notify('Failed to apply hunk: ' .. err, vim.log.levels.ERROR) - return - end - end - - vim.notify(string.format('Applied hunk to %s', hunk_info.file), vim.log.levels.INFO) - - -- Refresh the buffer if it's open - vim.cmd 'checktime' -end - -return M - diff --git a/lua/nvim-claude/git.lua b/lua/nvim-claude/git.lua deleted file mode 100644 index b0059116..00000000 --- a/lua/nvim-claude/git.lua +++ /dev/null @@ -1,186 +0,0 @@ --- Git operations module for nvim-claude -local M = {} -local utils = require('nvim-claude.utils') - -M.config = {} - -function M.setup(config) - M.config = config or {} -end - --- Check if git worktrees are supported -function M.supports_worktrees() - local result = utils.exec('git worktree list 2>/dev/null') - return result ~= nil and not result:match('error') -end - --- Get list of existing worktrees -function M.list_worktrees() - local result = utils.exec('git worktree list --porcelain') - if not result then return {} end - - local worktrees = {} - local current = {} - - for line in result:gmatch('[^\n]+') do - if line:match('^worktree ') then - if current.path then - table.insert(worktrees, current) - end - current = { path = line:match('^worktree (.+)') } - elseif line:match('^HEAD ') then - current.head = line:match('^HEAD (.+)') - elseif line:match('^branch ') then - current.branch = line:match('^branch (.+)') - end - end - - if current.path then - table.insert(worktrees, current) - end - - return worktrees -end - --- Create a new worktree -function M.create_worktree(path, branch) - branch = branch or M.default_branch() - - -- Check if worktree already exists - local worktrees = M.list_worktrees() - for _, wt in ipairs(worktrees) do - if wt.path == path then - return true, wt - end - end - - -- Generate unique branch name for the worktree - local worktree_branch = 'agent-' .. utils.timestamp() - - -- Create worktree with new branch based on specified branch - local cmd = string.format('git worktree add -b "%s" "%s" "%s" 2>&1', worktree_branch, path, branch) - local result, err = utils.exec(cmd) - - if err then - return false, result - end - - -- For background agents, ensure no hooks by creating empty .claude directory - -- This prevents inline diffs from triggering - local claude_dir = path .. '/.claude' - if vim.fn.isdirectory(claude_dir) == 0 then - vim.fn.mkdir(claude_dir, 'p') - end - - -- Create empty settings.json to disable hooks - local empty_settings = '{"hooks": {}}' - utils.write_file(claude_dir .. '/settings.json', empty_settings) - - return true, { path = path, branch = worktree_branch, base_branch = branch } -end - --- Remove a worktree -function M.remove_worktree(path) - -- First try to remove as git worktree - local cmd = string.format('git worktree remove "%s" --force 2>&1', path) - local result, err = utils.exec(cmd) - - -- If it's not a worktree or already removed, just delete the directory - if err or result:match('not a working tree') then - local rm_cmd = string.format('rm -rf "%s"', path) - utils.exec(rm_cmd) - end - - return true -end - --- Add entry to .gitignore -function M.add_to_gitignore(pattern) - local gitignore_path = utils.get_project_root() .. '/.gitignore' - - -- Read existing content - local content = utils.read_file(gitignore_path) or '' - - -- Check if pattern already exists - if content:match('\n' .. pattern:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1') .. '\n') or - content:match('^' .. pattern:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1') .. '\n') then - return true - end - - -- Append pattern - if not content:match('\n$') and content ~= '' then - content = content .. '\n' - end - content = content .. pattern .. '\n' - - return utils.write_file(gitignore_path, content) -end - --- Get current branch -function M.current_branch() - local result = utils.exec('git branch --show-current 2>/dev/null') - if result then - return result:gsub('\n', '') - end - return nil -end - --- Get default branch (usually main or master) -function M.default_branch() - -- Try to get the default branch from remote - local result = utils.exec('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null') - if result and result ~= '' then - local branch = result:match('refs/remotes/origin/(.+)') - if branch then - return branch:gsub('\n', '') - end - end - - -- Fallback: check if main or master exists - local main_exists = utils.exec('git show-ref --verify --quiet refs/heads/main') - if main_exists and main_exists == '' then - return 'main' - end - - local master_exists = utils.exec('git show-ref --verify --quiet refs/heads/master') - if master_exists and master_exists == '' then - return 'master' - end - - -- Final fallback - return 'main' -end - --- Get git status -function M.status(path) - local cmd = 'git status --porcelain' - if path then - cmd = string.format('cd "%s" && %s', path, cmd) - end - - local result = utils.exec(cmd) - if not result then return {} end - - local files = {} - for line in result:gmatch('[^\n]+') do - local status, file = line:match('^(..) (.+)$') - if status and file then - table.insert(files, { status = status, file = file }) - end - end - - return files -end - --- Get diff between two paths -function M.diff(path1, path2) - local cmd = string.format( - 'git diff --no-index --name-status "%s" "%s" 2>/dev/null', - path1, - path2 - ) - local result = utils.exec(cmd) - return result or '' -end - -return M \ No newline at end of file diff --git a/lua/nvim-claude/hooks.lua b/lua/nvim-claude/hooks.lua deleted file mode 100644 index 935e39c3..00000000 --- a/lua/nvim-claude/hooks.lua +++ /dev/null @@ -1,726 +0,0 @@ --- Claude Code hooks integration for nvim-claude -local M = {} - --- Track hook state -M.pre_edit_commit = nil -M.stable_baseline_ref = nil -- The stable baseline to compare all changes against -M.claude_edited_files = {} -- Track which files Claude has edited - --- Update stable baseline after accepting changes -function M.update_stable_baseline() - local utils = require('nvim-claude.utils') - local persistence = require('nvim-claude.inline-diff-persistence') - - -- Create a new stash with current state as the new baseline - local message = 'nvim-claude-baseline-accepted-' .. os.time() - - -- Create a stash object without removing changes from working directory - local stash_cmd = 'git stash create' - local stash_hash, err = utils.exec(stash_cmd) - - if not err and stash_hash and stash_hash ~= '' then - -- Store the stash with a message - stash_hash = stash_hash:gsub('%s+', '') -- trim whitespace - local store_cmd = string.format('git stash store -m "%s" %s', message, stash_hash) - utils.exec(store_cmd) - - -- Update our stable baseline reference - M.stable_baseline_ref = stash_hash - persistence.current_stash_ref = stash_hash - - -- Save the updated state - persistence.save_state({ stash_ref = stash_hash }) - end -end - -function M.setup() - -- Setup persistence layer on startup - vim.defer_fn(function() - M.setup_persistence() - end, 500) - - -- Set up autocmd for opening files - M.setup_file_open_autocmd() -end - --- Pre-tool-use hook: Create baseline stash if we don't have one -function M.pre_tool_use_hook() - local persistence = require 'nvim-claude.inline-diff-persistence' - - -- Only create a baseline if we don't have one yet - if not M.stable_baseline_ref then - -- Create baseline stash synchronously - local stash_ref = persistence.create_stash('nvim-claude: baseline ' .. os.date('%Y-%m-%d %H:%M:%S')) - if stash_ref then - M.stable_baseline_ref = stash_ref - persistence.current_stash_ref = stash_ref - end - end - - -- Return success to allow the tool to proceed - return true -end - --- Post-tool-use hook: Create stash of Claude's changes and trigger diff review -function M.post_tool_use_hook() - -- Run directly without vim.schedule for testing - local utils = require 'nvim-claude.utils' - local persistence = require 'nvim-claude.inline-diff-persistence' - - -- Refresh all buffers to show Claude's changes - vim.cmd 'checktime' - - -- Check if Claude made any changes - local git_root = utils.get_project_root() - if not git_root then - return - end - - local status_cmd = string.format('cd "%s" && git status --porcelain', git_root) - local status_result = utils.exec(status_cmd) - - if not status_result or status_result == '' then - return - end - - -- Get list of modified files - local modified_files = {} - local inline_diff = require 'nvim-claude.inline-diff' - - for line in status_result:gmatch '[^\n]+' do - local file = line:match '^.M (.+)$' or line:match '^M. (.+)$' or line:match '^.. (.+)$' - if file then - table.insert(modified_files, file) - -- Track that Claude edited this file - M.claude_edited_files[file] = true - - -- Track this file in the diff files list immediately - local full_path = git_root .. '/' .. file - inline_diff.diff_files[full_path] = -1 -- Use -1 to indicate no buffer yet - end - end - - -- Always use the stable baseline reference for comparison - local stash_ref = M.stable_baseline_ref or persistence.current_stash_ref - - -- If no baseline exists at all, create one now (shouldn't happen normally) - if not stash_ref then - stash_ref = persistence.create_stash('nvim-claude: baseline ' .. os.date('%Y-%m-%d %H:%M:%S')) - M.stable_baseline_ref = stash_ref - persistence.current_stash_ref = stash_ref - end - - if stash_ref then - -- Process inline diffs for currently open buffers - local opened_inline = false - - for _, file in ipairs(modified_files) do - local full_path = git_root .. '/' .. file - - -- Find buffer with this file - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then - local buf_name = vim.api.nvim_buf_get_name(buf) - if buf_name == full_path or buf_name:match('/' .. file:gsub('([^%w])', '%%%1') .. '$') then - -- Show inline diff for this open buffer - M.show_inline_diff_for_file(buf, file, git_root, stash_ref) - opened_inline = true - - -- Switch to that buffer if it's not the current one - if buf ~= vim.api.nvim_get_current_buf() then - vim.api.nvim_set_current_buf(buf) - end - - break -- Only show inline diff for first matching buffer - end - end - end - - if opened_inline then - break - end - end - - -- If no inline diff was shown, just notify the user - if not opened_inline then - vim.notify('Claude made changes. Open the modified files to see inline diffs.', vim.log.levels.INFO) - end - end -end - --- Helper function to show inline diff for a file -function M.show_inline_diff_for_file(buf, file, git_root, stash_ref) - local utils = require 'nvim-claude.utils' - local inline_diff = require 'nvim-claude.inline-diff' - - -- Only show inline diff if Claude edited this file - if not M.claude_edited_files[file] then - return false - end - - -- Get baseline from git stash - local stash_cmd = string.format('cd "%s" && git show %s:%s 2>/dev/null', git_root, stash_ref, file) - local original_content = utils.exec(stash_cmd) - - if original_content then - -- Get current content - local current_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local current_content = table.concat(current_lines, '\n') - - -- Show inline diff - inline_diff.show_inline_diff(buf, original_content, current_content) - return true - end - - return false -end - --- Test inline diff manually -function M.test_inline_diff() - vim.notify('Testing inline diff manually...', vim.log.levels.INFO) - - local utils = require 'nvim-claude.utils' - local persistence = require 'nvim-claude.inline-diff-persistence' - local git_root = utils.get_project_root() - - if not git_root then - vim.notify('Not in git repository', vim.log.levels.ERROR) - return - end - - -- Get current buffer - local bufnr = vim.api.nvim_get_current_buf() - local buf_name = vim.api.nvim_buf_get_name(bufnr) - - if buf_name == '' then - vim.notify('Current buffer has no file', vim.log.levels.ERROR) - return - end - - -- Get relative path - local relative_path = buf_name:gsub(git_root .. '/', '') - vim.notify('Testing inline diff for: ' .. relative_path, vim.log.levels.INFO) - - -- Get baseline content - check for updated baseline first - local inline_diff = require 'nvim-claude.inline-diff' - local original_content = nil - - -- Check if we have an updated baseline in memory - vim.notify('DEBUG: Checking for baseline in buffer ' .. bufnr, vim.log.levels.INFO) - vim.notify('DEBUG: Available baselines: ' .. vim.inspect(vim.tbl_keys(inline_diff.original_content)), vim.log.levels.INFO) - - if inline_diff.original_content[bufnr] then - original_content = inline_diff.original_content[bufnr] - vim.notify('Using updated baseline from memory (length: ' .. #original_content .. ')', vim.log.levels.INFO) - elseif persistence.current_stash_ref then - -- Try to get from stash - local stash_cmd = string.format('cd "%s" && git show %s:%s 2>/dev/null', git_root, persistence.current_stash_ref, relative_path) - local git_err - original_content, git_err = utils.exec(stash_cmd) - - if git_err then - vim.notify('Failed to get stash content: ' .. git_err, vim.log.levels.ERROR) - return - end - vim.notify('Using stash baseline: ' .. persistence.current_stash_ref, vim.log.levels.INFO) - else - -- Fall back to HEAD - local baseline_cmd = string.format('cd "%s" && git show HEAD:%s 2>/dev/null', git_root, relative_path) - local git_err - original_content, git_err = utils.exec(baseline_cmd) - - if git_err then - vim.notify('Failed to get baseline content: ' .. git_err, vim.log.levels.ERROR) - return - end - vim.notify('Using HEAD as baseline', vim.log.levels.INFO) - end - - -- Get current content - local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local current_content = table.concat(current_lines, '\n') - - -- Show inline diff - inline_diff.show_inline_diff(bufnr, original_content, current_content) -end - --- Set up autocmd to check for diffs when opening files -function M.setup_file_open_autocmd() - vim.api.nvim_create_autocmd({"BufRead", "BufNewFile"}, { - pattern = "*", - callback = function(args) - local bufnr = args.buf - local file_path = vim.api.nvim_buf_get_name(bufnr) - - if file_path == '' then return end - - local utils = require 'nvim-claude.utils' - local git_root = utils.get_project_root() - - if not git_root then return end - - -- Get relative path - local relative_path = file_path:gsub(git_root .. '/', '') - - -- Check if this file was edited by Claude - if M.claude_edited_files[relative_path] and M.stable_baseline_ref then - -- Show inline diff for this file - vim.defer_fn(function() - M.show_inline_diff_for_file(bufnr, relative_path, git_root, M.stable_baseline_ref) - end, 50) -- Small delay to ensure buffer is fully loaded - end - end, - group = vim.api.nvim_create_augroup('NvimClaudeFileOpen', { clear = true }) - }) -end - --- Setup persistence and restore saved state on Neovim startup -function M.setup_persistence() - local persistence = require 'nvim-claude.inline-diff-persistence' - - -- Setup persistence autocmds - persistence.setup_autocmds() - - -- Try to restore any saved diffs - local restored = persistence.restore_diffs() - - -- Also restore the baseline reference from persistence if it exists - if persistence.current_stash_ref then - M.stable_baseline_ref = persistence.current_stash_ref - vim.notify('Restored baseline: ' .. M.stable_baseline_ref, vim.log.levels.DEBUG) - end - - -- Don't create a startup baseline - only create baselines when Claude makes edits -end - --- Manual hook testing -function M.test_hooks() - vim.notify('=== Testing nvim-claude hooks ===', vim.log.levels.INFO) - - local persistence = require 'nvim-claude.inline-diff-persistence' - - -- Test creating a stash - vim.notify('1. Creating test stash...', vim.log.levels.INFO) - local stash_ref = persistence.create_stash('nvim-claude: test stash') - - if stash_ref then - persistence.current_stash_ref = stash_ref - vim.notify('Stash created: ' .. stash_ref, vim.log.levels.INFO) - else - vim.notify('Failed to create stash', vim.log.levels.ERROR) - end - - -- Simulate making a change - vim.notify('2. Make some changes to test files now...', vim.log.levels.INFO) - - -- Test post-tool-use hook after a delay - vim.notify('3. Will trigger post-tool-use hook in 3 seconds...', vim.log.levels.INFO) - - vim.defer_fn(function() - M.post_tool_use_hook() - end, 3000) - - vim.notify('=== Hook testing started - make changes now! ===', vim.log.levels.INFO) -end - --- Install Claude Code hooks -function M.install_hooks() - local utils = require 'nvim-claude.utils' - - -- Get project root - local project_root = utils.get_project_root() - if not project_root then - vim.notify('Not in a git repository', vim.log.levels.ERROR) - return - end - - -- Create .claude directory - local claude_dir = project_root .. '/.claude' - if not vim.fn.isdirectory(claude_dir) then - vim.fn.mkdir(claude_dir, 'p') - end - - -- Create hooks configuration - local server_name = vim.v.servername or 'NVIM' - local pre_command = string.format( - 'nvim --headless --server %s --remote-send ":lua require(\'nvim-claude.hooks\').pre_tool_use_hook()" 2>/dev/null || true', - server_name - ) - local post_command = string.format( - 'nvim --headless --server %s --remote-send ":lua require(\'nvim-claude.hooks\').post_tool_use_hook()" 2>/dev/null || true', - server_name - ) - - local hooks_config = { - hooks = { - PreToolUse = { - { - matcher = 'Edit|Write|MultiEdit', -- Only match file editing tools - hooks = { - { - type = 'command', - command = pre_command, - }, - }, - }, - }, - PostToolUse = { - { - matcher = 'Edit|Write|MultiEdit', -- Only match file editing tools - hooks = { - { - type = 'command', - command = post_command, - }, - }, - }, - }, - }, - } - - -- Write hooks configuration - local settings_file = claude_dir .. '/settings.json' - local success, err = utils.write_json(settings_file, hooks_config) - - if success then - -- Add .claude to gitignore if needed - local gitignore_path = project_root .. '/.gitignore' - local gitignore_content = utils.read_file(gitignore_path) or '' - - if not gitignore_content:match '%.claude/' then - local new_content = gitignore_content .. '\n# Claude Code hooks\n.claude/\n' - utils.write_file(gitignore_path, new_content) - vim.notify('Added .claude/ to .gitignore', vim.log.levels.INFO) - end - - vim.notify('Claude Code hooks installed successfully', vim.log.levels.INFO) - vim.notify('Hooks configuration written to: ' .. settings_file, vim.log.levels.INFO) - else - vim.notify('Failed to install hooks: ' .. (err or 'unknown error'), vim.log.levels.ERROR) - end -end - --- Uninstall Claude Code hooks -function M.uninstall_hooks() - local utils = require 'nvim-claude.utils' - - -- Get project root - local project_root = utils.get_project_root() - if not project_root then - vim.notify('Not in a git repository', vim.log.levels.ERROR) - return - end - - local settings_file = project_root .. '/.claude/settings.json' - - if vim.fn.filereadable(settings_file) then - vim.fn.delete(settings_file) - vim.notify('Claude Code hooks uninstalled', vim.log.levels.INFO) - else - vim.notify('No hooks configuration found', vim.log.levels.INFO) - end -end - --- Commands for manual hook management -function M.setup_commands() - vim.api.nvim_create_user_command('ClaudeTestHooks', function() - M.test_hooks() - end, { - desc = 'Test Claude Code hooks', - }) - - vim.api.nvim_create_user_command('ClaudeTestInlineDiff', function() - M.test_inline_diff() - end, { - desc = 'Test Claude inline diff manually', - }) - - vim.api.nvim_create_user_command('ClaudeTestKeymap', function() - require('nvim-claude.inline-diff').test_keymap() - end, { - desc = 'Test Claude keymap functionality', - }) - - vim.api.nvim_create_user_command('ClaudeDebugInlineDiff', function() - require('nvim-claude.inline-diff-debug').debug_inline_diff() - end, { - desc = 'Debug Claude inline diff state', - }) - - vim.api.nvim_create_user_command('ClaudeUpdateBaseline', function() - local bufnr = vim.api.nvim_get_current_buf() - local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local current_content = table.concat(current_lines, '\n') - - local inline_diff = require 'nvim-claude.inline-diff' - inline_diff.original_content[bufnr] = current_content - - -- Save updated state - local persistence = require 'nvim-claude.inline-diff-persistence' - if persistence.current_stash_ref then - persistence.save_state({ stash_ref = persistence.current_stash_ref }) - end - - vim.notify('Baseline updated to current buffer state', vim.log.levels.INFO) - end, { - desc = 'Update Claude baseline to current buffer state', - }) - - vim.api.nvim_create_user_command('ClaudeTestDiff', function() - local utils = require 'nvim-claude.utils' - - -- Check if we're in a git repository - local git_root = utils.get_project_root() - if not git_root then - return - end - - -- Check if there are any changes - local status_cmd = string.format('cd "%s" && git status --porcelain', git_root) - local status_result = utils.exec(status_cmd) - - if not status_result or status_result == '' then - vim.notify('No changes to test', vim.log.levels.INFO) - return - end - - -- Create test stash without restoring (to avoid conflicts) - local timestamp = os.date '%Y-%m-%d %H:%M:%S' - local stash_msg = string.format('[claude-test] %s', timestamp) - - local stash_cmd = string.format('cd "%s" && git stash push -u -m "%s"', git_root, stash_msg) - local stash_result, stash_err = utils.exec(stash_cmd) - - if stash_err then - vim.notify('Failed to create test stash: ' .. stash_err, vim.log.levels.ERROR) - return - end - - -- Trigger diff review with the stash (no pre-edit ref for manual test) - local diff_review = require 'nvim-claude.diff-review' - diff_review.handle_claude_edit('stash@{0}', nil) - - -- Pop the stash to restore changes - vim.defer_fn(function() - local pop_cmd = string.format('cd "%s" && git stash pop --quiet', git_root) - utils.exec(pop_cmd) - vim.cmd 'checktime' -- Refresh buffers - end, 100) - end, { - desc = 'Test Claude diff review with current changes', - }) - - vim.api.nvim_create_user_command('ClaudeInstallHooks', function() - M.install_hooks() - end, { - desc = 'Install Claude Code hooks for this project', - }) - - vim.api.nvim_create_user_command('ClaudeUninstallHooks', function() - M.uninstall_hooks() - end, { - desc = 'Uninstall Claude Code hooks for this project', - }) - - vim.api.nvim_create_user_command('ClaudeResetBaseline', function() - -- Clear all baselines and force new baseline on next edit - local inline_diff = require 'nvim-claude.inline-diff' - local persistence = require 'nvim-claude.inline-diff-persistence' - - -- Clear in-memory baselines - inline_diff.original_content = {} - - -- Clear stable baseline reference - M.stable_baseline_ref = nil - persistence.current_stash_ref = nil - M.claude_edited_files = {} - - -- Clear persistence state - persistence.clear_state() - - vim.notify('Baseline reset. Next edit will create a new baseline.', vim.log.levels.INFO) - end, { - desc = 'Reset Claude baseline for cumulative diffs', - }) - - vim.api.nvim_create_user_command('ClaudeAcceptAll', function() - local inline_diff = require 'nvim-claude.inline-diff' - inline_diff.accept_all_files() - end, { - desc = 'Accept all Claude diffs across all files', - }) - - vim.api.nvim_create_user_command('ClaudeTrackModified', function() - -- Manually track all modified files as Claude-edited - local utils = require 'nvim-claude.utils' - local git_root = utils.get_project_root() - - if not git_root then - vim.notify('Not in a git repository', vim.log.levels.ERROR) - return - end - - local status_cmd = string.format('cd "%s" && git status --porcelain', git_root) - local status_result = utils.exec(status_cmd) - - if not status_result or status_result == '' then - vim.notify('No modified files found', vim.log.levels.INFO) - return - end - - local count = 0 - for line in status_result:gmatch '[^\n]+' do - local file = line:match '^.M (.+)$' or line:match '^M. (.+)$' - if file then - M.claude_edited_files[file] = true - count = count + 1 - end - end - - vim.notify(string.format('Tracked %d modified files as Claude-edited', count), vim.log.levels.INFO) - - -- Also ensure we have a baseline - if not M.stable_baseline_ref then - local persistence = require 'nvim-claude.inline-diff-persistence' - local stash_list = utils.exec('git stash list | grep "nvim-claude: baseline" | head -1') - if stash_list and stash_list ~= '' then - local stash_ref = stash_list:match('^(stash@{%d+})') - if stash_ref then - M.stable_baseline_ref = stash_ref - persistence.current_stash_ref = stash_ref - vim.notify('Using baseline: ' .. stash_ref, vim.log.levels.INFO) - end - end - end - end, { - desc = 'Track all modified files as Claude-edited (for debugging)', - }) - - vim.api.nvim_create_user_command('ClaudeDebugTracking', function() - -- Debug command to show current tracking state - local inline_diff = require 'nvim-claude.inline-diff' - local persistence = require 'nvim-claude.inline-diff-persistence' - local utils = require 'nvim-claude.utils' - - vim.notify('=== Claude Tracking Debug ===', vim.log.levels.INFO) - vim.notify('Stable baseline: ' .. (M.stable_baseline_ref or 'none'), vim.log.levels.INFO) - vim.notify('Persistence stash ref: ' .. (persistence.current_stash_ref or 'none'), vim.log.levels.INFO) - vim.notify('Claude edited files: ' .. vim.inspect(M.claude_edited_files), vim.log.levels.INFO) - vim.notify('Diff files: ' .. vim.inspect(vim.tbl_keys(inline_diff.diff_files)), vim.log.levels.INFO) - vim.notify('Active diffs: ' .. vim.inspect(vim.tbl_keys(inline_diff.active_diffs)), vim.log.levels.INFO) - - -- Check current file - local current_file = vim.api.nvim_buf_get_name(0) - local git_root = utils.get_project_root() - if git_root then - local relative_path = current_file:gsub('^' .. vim.pesc(git_root) .. '/', '') - vim.notify('Current file relative path: ' .. relative_path, vim.log.levels.INFO) - vim.notify('Is tracked: ' .. tostring(M.claude_edited_files[relative_path] ~= nil), vim.log.levels.INFO) - end - end, { - desc = 'Debug Claude tracking state', - }) - - vim.api.nvim_create_user_command('ClaudeRestoreState', function() - -- Manually restore the state - local persistence = require 'nvim-claude.inline-diff-persistence' - local restored = persistence.restore_diffs() - - if persistence.current_stash_ref then - M.stable_baseline_ref = persistence.current_stash_ref - end - - vim.notify('Manually restored state', vim.log.levels.INFO) - end, { - desc = 'Manually restore Claude diff state', - }) - - vim.api.nvim_create_user_command('ClaudeCleanStaleTracking', function() - local utils = require 'nvim-claude.utils' - local persistence = require 'nvim-claude.inline-diff-persistence' - local git_root = utils.get_project_root() - - if not git_root or not M.stable_baseline_ref then - vim.notify('No git root or baseline found', vim.log.levels.ERROR) - return - end - - local cleaned_count = 0 - local files_to_remove = {} - - -- Check each tracked file for actual differences - for file_path, _ in pairs(M.claude_edited_files) do - local diff_cmd = string.format('cd "%s" && git diff %s -- "%s" 2>/dev/null', git_root, M.stable_baseline_ref, file_path) - local diff_output = utils.exec(diff_cmd) - - if not diff_output or diff_output == '' then - -- No differences, remove from tracking - table.insert(files_to_remove, file_path) - cleaned_count = cleaned_count + 1 - end - end - - -- Remove files with no differences - for _, file_path in ipairs(files_to_remove) do - M.claude_edited_files[file_path] = nil - end - - -- Save updated state if we have a persistence stash ref - if persistence.current_stash_ref then - persistence.save_state({ stash_ref = persistence.current_stash_ref }) - end - - vim.notify(string.format('Cleaned %d stale tracked files', cleaned_count), vim.log.levels.INFO) - end, { - desc = 'Clean up stale Claude file tracking', - }) - - vim.api.nvim_create_user_command('ClaudeUntrackFile', function() - -- Remove current file from Claude tracking - local utils = require 'nvim-claude.utils' - local git_root = utils.get_project_root() - - if not git_root then - vim.notify('Not in a git repository', vim.log.levels.ERROR) - return - end - - local file_path = vim.api.nvim_buf_get_name(0) - local relative_path = file_path:gsub(git_root .. '/', '') - - if M.claude_edited_files[relative_path] then - M.claude_edited_files[relative_path] = nil - vim.notify('Removed ' .. relative_path .. ' from Claude tracking', vim.log.levels.INFO) - - -- Also close any active inline diff for this buffer - local inline_diff = require 'nvim-claude.inline-diff' - local bufnr = vim.api.nvim_get_current_buf() - if inline_diff.has_active_diff(bufnr) then - inline_diff.close_inline_diff(bufnr) - end - else - vim.notify(relative_path .. ' is not in Claude tracking', vim.log.levels.INFO) - end - end, { - desc = 'Remove current file from Claude edited files tracking', - }) -end - --- Cleanup old temp files (no longer cleans up commits) -function M.cleanup_old_files() - -- Clean up old temp files - local temp_files = { - '/tmp/claude-pre-edit-commit', - '/tmp/claude-baseline-commit', - '/tmp/claude-last-snapshot', - '/tmp/claude-hook-test.log', - } - - for _, file in ipairs(temp_files) do - if vim.fn.filereadable(file) == 1 then - vim.fn.delete(file) - end - end -end - -return M diff --git a/lua/nvim-claude/init.lua b/lua/nvim-claude/init.lua deleted file mode 100644 index 54ebe408..00000000 --- a/lua/nvim-claude/init.lua +++ /dev/null @@ -1,153 +0,0 @@ --- nvim-claude: Claude integration for Neovim with tmux workflow -local M = {} - --- Default configuration -M.config = { - tmux = { - split_direction = 'h', -- horizontal split - split_size = 40, -- 40% width - session_prefix = 'claude-', - pane_title = 'claude-chat', - }, - agents = { - work_dir = '.agent-work', - use_worktrees = true, - auto_gitignore = true, - max_agents = 5, - cleanup_days = 7, - }, - ui = { - float_diff = true, - telescope_preview = true, - status_line = true, - }, - mappings = { - prefix = 'c', - quick_prefix = '', - }, -} - --- Validate configuration -local function validate_config(config) - local ok = true - local errors = {} - - -- Validate tmux settings - if config.tmux then - if config.tmux.split_direction and - config.tmux.split_direction ~= 'h' and - config.tmux.split_direction ~= 'v' then - table.insert(errors, "tmux.split_direction must be 'h' or 'v'") - ok = false - end - - if config.tmux.split_size and - (type(config.tmux.split_size) ~= 'number' or - config.tmux.split_size < 1 or - config.tmux.split_size > 99) then - table.insert(errors, "tmux.split_size must be a number between 1 and 99") - ok = false - end - end - - -- Validate agent settings - if config.agents then - if config.agents.max_agents and - (type(config.agents.max_agents) ~= 'number' or - config.agents.max_agents < 1) then - table.insert(errors, "agents.max_agents must be a positive number") - ok = false - end - - if config.agents.cleanup_days and - (type(config.agents.cleanup_days) ~= 'number' or - config.agents.cleanup_days < 0) then - table.insert(errors, "agents.cleanup_days must be a non-negative number") - ok = false - end - - if config.agents.work_dir and - (type(config.agents.work_dir) ~= 'string' or - config.agents.work_dir:match('^/') or - config.agents.work_dir:match('%.%.')) then - table.insert(errors, "agents.work_dir must be a relative path without '..'") - ok = false - end - end - - -- Validate mappings - if config.mappings then - if config.mappings.prefix and - type(config.mappings.prefix) ~= 'string' then - table.insert(errors, "mappings.prefix must be a string") - ok = false - end - end - - return ok, errors -end - --- Merge user config with defaults -local function merge_config(user_config) - local merged = vim.tbl_deep_extend('force', M.config, user_config or {}) - - -- Validate the merged config - local ok, errors = validate_config(merged) - if not ok then - vim.notify('nvim-claude: Configuration errors:', vim.log.levels.ERROR) - for _, err in ipairs(errors) do - vim.notify(' - ' .. err, vim.log.levels.ERROR) - end - vim.notify('Using default configuration', vim.log.levels.WARN) - return M.config - end - - return merged -end - --- Plugin setup -function M.setup(user_config) - M.config = merge_config(user_config) - - -- Force reload modules to ensure latest code - package.loaded['nvim-claude.hooks'] = nil - package.loaded['nvim-claude.diff-review'] = nil - - -- Load submodules - M.tmux = require('nvim-claude.tmux') - M.git = require('nvim-claude.git') - M.utils = require('nvim-claude.utils') - M.commands = require('nvim-claude.commands') - M.registry = require('nvim-claude.registry') - M.hooks = require('nvim-claude.hooks') - M.diff_review = require('nvim-claude.diff-review') - M.settings_updater = require('nvim-claude.settings-updater') - - -- Initialize submodules with config - M.tmux.setup(M.config.tmux) - M.git.setup(M.config.agents) - M.registry.setup(M.config.agents) - M.hooks.setup() - M.diff_review.setup() - M.settings_updater.setup() - - -- Set up commands - M.commands.setup(M) - M.hooks.setup_commands() - - -- Auto-install hooks if we're in a git repository - vim.defer_fn(function() - if M.utils.get_project_root() then - M.hooks.install_hooks() - end - end, 100) - - -- Set up keymappings if enabled - if M.config.mappings then - require('nvim-claude.mappings').setup(M.config.mappings, M.commands) - end - - vim.notify('nvim-claude loaded', vim.log.levels.INFO) -end - -return M \ No newline at end of file diff --git a/lua/nvim-claude/inline-diff-debug.lua b/lua/nvim-claude/inline-diff-debug.lua deleted file mode 100644 index e5252904..00000000 --- a/lua/nvim-claude/inline-diff-debug.lua +++ /dev/null @@ -1,57 +0,0 @@ -local M = {} - --- Debug function to check inline diff state -function M.debug_inline_diff() - local inline_diff = require('nvim-claude.inline-diff') - local bufnr = vim.api.nvim_get_current_buf() - - vim.notify('=== Inline Diff Debug Info ===', vim.log.levels.INFO) - - -- Check if inline diff is active for current buffer - local diff_data = inline_diff.active_diffs[bufnr] - if diff_data then - vim.notify(string.format('✓ Inline diff ACTIVE for buffer %d', bufnr), vim.log.levels.INFO) - vim.notify(string.format(' - Hunks: %d', #diff_data.hunks), vim.log.levels.INFO) - vim.notify(string.format(' - Current hunk: %d', diff_data.current_hunk or 0), vim.log.levels.INFO) - vim.notify(string.format(' - Original content length: %d', #(inline_diff.original_content[bufnr] or '')), vim.log.levels.INFO) - vim.notify(string.format(' - New content length: %d', #(diff_data.new_content or '')), vim.log.levels.INFO) - else - vim.notify(string.format('✗ No inline diff for buffer %d', bufnr), vim.log.levels.WARN) - end - - -- Check all active diffs - local count = 0 - for buf, _ in pairs(inline_diff.active_diffs) do - count = count + 1 - end - vim.notify(string.format('Total active inline diffs: %d', count), vim.log.levels.INFO) - - -- Check keymaps - local keymaps = vim.api.nvim_buf_get_keymap(bufnr, 'n') - local found_ir = false - local leader = vim.g.mapleader or '\\' - local ir_pattern = leader .. 'ir' - - vim.notify(string.format('Looking for keymap: %s', ir_pattern), vim.log.levels.INFO) - - for _, map in ipairs(keymaps) do - if map.lhs == ir_pattern or map.lhs == 'ir' then - found_ir = true - vim.notify(string.format('✓ Found keymap: %s -> %s', map.lhs, map.desc or 'no desc'), vim.log.levels.INFO) - break - end - end - - if not found_ir then - vim.notify('✗ ir keymap not found', vim.log.levels.WARN) - -- List all keymaps that start with leader - vim.notify('Buffer keymaps starting with leader:', vim.log.levels.INFO) - for _, map in ipairs(keymaps) do - if map.lhs:match('^' .. vim.pesc(leader)) or map.lhs:match('^') then - vim.notify(string.format(' %s -> %s', map.lhs, map.desc or 'no desc'), vim.log.levels.INFO) - end - end - end -end - -return M \ No newline at end of file diff --git a/lua/nvim-claude/inline-diff-persistence.lua b/lua/nvim-claude/inline-diff-persistence.lua deleted file mode 100644 index 91642a58..00000000 --- a/lua/nvim-claude/inline-diff-persistence.lua +++ /dev/null @@ -1,314 +0,0 @@ --- Persistence layer for inline diffs --- Manages saving/loading diff state across neovim sessions without polluting git history - -local M = {} -local utils = require('nvim-claude.utils') - --- State file location -M.state_file = vim.fn.stdpath('data') .. '/nvim-claude-inline-diff-state.json' - --- Save current diff state -function M.save_state(diff_data) - -- Structure: - -- { - -- version: 1, - -- timestamp: , - -- stash_ref: "stash@{0}", - -- files: { - -- "/path/to/file": { - -- original_content: "...", - -- hunks: [...], - -- applied_hunks: {...} - -- } - -- } - -- } - - local hooks = require('nvim-claude.hooks') - local inline_diff = require('nvim-claude.inline-diff') - - local state = { - version = 1, - timestamp = os.time(), - stash_ref = diff_data.stash_ref, - claude_edited_files = hooks.claude_edited_files or {}, - diff_files = {}, -- Add diff_files to persistence - files = {} - } - - -- Save all diff files (both opened and unopened) - for file_path, bufnr in pairs(inline_diff.diff_files) do - state.diff_files[file_path] = bufnr - end - - -- Collect state from all buffers with active diffs - for file_path, bufnr in pairs(inline_diff.diff_files) do - if inline_diff.active_diffs[bufnr] then - local diff = inline_diff.active_diffs[bufnr] - state.files[file_path] = { - original_content = inline_diff.original_content[bufnr], - hunks = diff.hunks, - applied_hunks = diff.applied_hunks or {}, - new_content = diff.new_content - } - end - end - - -- Save to file - local success, err = utils.write_json(M.state_file, state) - if not success then - vim.notify('Failed to save inline diff state: ' .. err, vim.log.levels.ERROR) - return false - end - - return true -end - --- Load saved diff state -function M.load_state() - if not utils.file_exists(M.state_file) then - return nil - end - - local state, err = utils.read_json(M.state_file) - if not state then - vim.notify('Failed to load inline diff state: ' .. err, vim.log.levels.ERROR) - return nil - end - - -- Validate version - if state.version ~= 1 then - vim.notify('Incompatible inline diff state version', vim.log.levels.WARN) - return nil - end - - -- Check if stash still exists - if state.stash_ref then - local cmd = string.format('git stash list | grep -q "%s"', state.stash_ref:gsub("{", "\\{"):gsub("}", "\\}")) - local result = os.execute(cmd) - if result ~= 0 then - vim.notify('Saved stash no longer exists: ' .. state.stash_ref, vim.log.levels.WARN) - M.clear_state() - return nil - end - end - - return state -end - --- Clear saved state -function M.clear_state() - if utils.file_exists(M.state_file) then - os.remove(M.state_file) - end -end - --- Restore diffs from saved state -function M.restore_diffs() - local state = M.load_state() - if not state then - return false - end - - local inline_diff = require('nvim-claude.inline-diff') - local restored_count = 0 - - -- Restore diffs for each file - for file_path, file_state in pairs(state.files) do - -- Check if file exists and hasn't changed since the diff was created - if utils.file_exists(file_path) then - -- Read current content - local current_content = utils.read_file(file_path) - - -- Check if the file matches what we expect (either original or with applied changes) - -- This handles the case where some hunks were accepted - if current_content then - -- Find or create buffer for this file - local bufnr = vim.fn.bufnr(file_path) - if bufnr == -1 then - -- File not loaded, we'll restore when it's opened - -- Store in a pending restores table - M.pending_restores = M.pending_restores or {} - M.pending_restores[file_path] = file_state - else - -- Restore the diff visualization - inline_diff.original_content[bufnr] = file_state.original_content - inline_diff.diff_files[file_path] = bufnr - inline_diff.active_diffs[bufnr] = { - hunks = file_state.hunks, - new_content = file_state.new_content, - current_hunk = 1, - applied_hunks = file_state.applied_hunks or {} - } - - -- Apply visualization - inline_diff.apply_diff_visualization(bufnr) - inline_diff.setup_inline_keymaps(bufnr) - - restored_count = restored_count + 1 - end - end - end - end - - if restored_count > 0 then - -- Silent restore - no notification - end - - -- Store the stash reference for future operations - M.current_stash_ref = state.stash_ref - - if M.current_stash_ref then - vim.notify('Restored stash reference: ' .. M.current_stash_ref, vim.log.levels.DEBUG) - end - - -- Restore Claude edited files tracking - if state.claude_edited_files then - local hooks = require('nvim-claude.hooks') - hooks.claude_edited_files = state.claude_edited_files - vim.notify(string.format('Restored %d Claude edited files', vim.tbl_count(state.claude_edited_files)), vim.log.levels.DEBUG) - end - - -- Restore diff_files for unopened files - if state.diff_files then - for file_path, bufnr in pairs(state.diff_files) do - -- Only restore if not already restored as an active diff - if not inline_diff.diff_files[file_path] then - -- Use -1 to indicate unopened file - inline_diff.diff_files[file_path] = bufnr == -1 and -1 or -1 - end - end - vim.notify(string.format('Restored %d diff files', vim.tbl_count(state.diff_files)), vim.log.levels.DEBUG) - end - - -- Also populate diff_files from claude_edited_files if needed - -- This ensures ci works even if diff_files wasn't properly saved - if state.claude_edited_files then - local utils = require('nvim-claude.utils') - local git_root = utils.get_project_root() - - if git_root then - for relative_path, _ in pairs(state.claude_edited_files) do - local full_path = git_root .. '/' .. relative_path - -- Only add if not already in diff_files - if not inline_diff.diff_files[full_path] then - inline_diff.diff_files[full_path] = -1 -- Mark as unopened - vim.notify('Added ' .. relative_path .. ' to diff_files from claude_edited_files', vim.log.levels.DEBUG) - end - end - end - end - - return true -end - --- Check for pending restores when a buffer is loaded -function M.check_pending_restore(bufnr) - if not M.pending_restores then - return - end - - local file_path = vim.api.nvim_buf_get_name(bufnr) - local file_state = M.pending_restores[file_path] - - if file_state then - local inline_diff = require('nvim-claude.inline-diff') - - -- Restore the diff for this buffer - inline_diff.original_content[bufnr] = file_state.original_content - inline_diff.diff_files[file_path] = bufnr - inline_diff.active_diffs[bufnr] = { - hunks = file_state.hunks, - new_content = file_state.new_content, - current_hunk = 1, - applied_hunks = file_state.applied_hunks or {} - } - - -- Apply visualization - inline_diff.apply_diff_visualization(bufnr) - inline_diff.setup_inline_keymaps(bufnr) - - -- Remove from pending - M.pending_restores[file_path] = nil - - -- Silent restore - no notification - end -end - --- Create a stash of current changes (instead of baseline commit) -function M.create_stash(message) - local utils = require('nvim-claude.utils') - message = message or 'nvim-claude: pre-edit state' - - -- Create a stash object without removing changes from working directory - local stash_cmd = 'git stash create' - local stash_hash, err = utils.exec(stash_cmd) - - if not err and stash_hash and stash_hash ~= '' then - -- Store the stash with a message - stash_hash = stash_hash:gsub('%s+', '') -- trim whitespace - local store_cmd = string.format('git stash store -m "%s" %s', message, stash_hash) - utils.exec(store_cmd) - - -- Get the stash reference - local stash_list = utils.exec('git stash list -n 1') - if stash_list then - local stash_ref = stash_list:match('^(stash@{%d+})') - return stash_ref - end - end - - return nil -end - --- Setup autocmds for persistence -function M.setup_autocmds() - local group = vim.api.nvim_create_augroup('NvimClaudeInlineDiffPersistence', { clear = true }) - - -- Save state before exiting vim - vim.api.nvim_create_autocmd('VimLeavePre', { - group = group, - callback = function() - local inline_diff = require('nvim-claude.inline-diff') - -- Only save if there are active diffs - local has_active_diffs = false - for _, diff in pairs(inline_diff.active_diffs) do - if diff then - has_active_diffs = true - break - end - end - - if has_active_diffs and M.current_stash_ref then - M.save_state({ stash_ref = M.current_stash_ref }) - else - -- Save just the Claude edited files tracking even if no active diffs - local hooks = require('nvim-claude.hooks') - if hooks.claude_edited_files and next(hooks.claude_edited_files) then - M.save_state({ stash_ref = M.current_stash_ref or '' }) - end - end - end - }) - - -- Check for pending restores when buffers are loaded - vim.api.nvim_create_autocmd('BufReadPost', { - group = group, - callback = function(ev) - M.check_pending_restore(ev.buf) - end - }) - - -- Auto-restore on VimEnter - vim.api.nvim_create_autocmd('VimEnter', { - group = group, - once = true, - callback = function() - -- Delay slightly to ensure everything is loaded - vim.defer_fn(function() - M.restore_diffs() - end, 100) - end - }) -end - -return M \ No newline at end of file diff --git a/lua/nvim-claude/inline-diff.lua b/lua/nvim-claude/inline-diff.lua deleted file mode 100644 index 99037510..00000000 --- a/lua/nvim-claude/inline-diff.lua +++ /dev/null @@ -1,1000 +0,0 @@ --- Inline diff viewer for nvim-claude --- Shows Claude's changes directly in the current buffer with accept/reject functionality - -local M = {} - --- Namespace for virtual text and highlights -local ns_id = vim.api.nvim_create_namespace('nvim_claude_inline_diff') - --- State tracking -M.active_diffs = {} -- Track active inline diffs by buffer number -M.original_content = {} -- Store original buffer content -M.diff_files = {} -- Track all files with diffs for navigation - --- Initialize inline diff for a buffer -function M.show_inline_diff(bufnr, old_content, new_content) - bufnr = bufnr or vim.api.nvim_get_current_buf() - - -- Store original content - M.original_content[bufnr] = old_content - - -- Track this file in our diff files list - local file_path = vim.api.nvim_buf_get_name(bufnr) - if file_path and file_path ~= '' then - M.diff_files[file_path] = bufnr - end - - -- Get the diff between old and new content - local diff_data = M.compute_diff(old_content, new_content) - - if not diff_data or #diff_data.hunks == 0 then - vim.notify('No changes to display', vim.log.levels.INFO) - return - end - - -- Store diff data for this buffer - M.active_diffs[bufnr] = { - hunks = diff_data.hunks, - new_content = new_content, - current_hunk = 1, - applied_hunks = {} - } - - -- Debug: Log target content length - -- vim.notify(string.format('DEBUG: Stored target content with %d chars', #new_content), vim.log.levels.WARN) - - -- Apply visual indicators - M.apply_diff_visualization(bufnr) - - -- Set up buffer-local keymaps - M.setup_inline_keymaps(bufnr) - - -- Jump to first hunk - M.jump_to_hunk(bufnr, 1) - - -- Silent activation - no notification -end - --- Compute diff between two texts -function M.compute_diff(old_text, new_text) - local utils = require('nvim-claude.utils') - - -- Write texts to temp files - local old_file = '/tmp/nvim-claude-old.txt' - local new_file = '/tmp/nvim-claude-new.txt' - - utils.write_file(old_file, old_text) - utils.write_file(new_file, new_text) - - -- Use git diff with histogram algorithm for better code diffs - local cmd = string.format( - 'git diff --no-index --no-prefix --unified=1 --diff-algorithm=histogram "%s" "%s" 2>/dev/null || true', - old_file, new_file - ) - local diff_output = utils.exec(cmd) - - -- Debug: save raw diff - utils.write_file('/tmp/nvim-claude-raw-diff.txt', diff_output) - - -- Parse diff into hunks - local hunks = M.parse_diff(diff_output) - - return { - hunks = hunks - } -end - --- Parse unified diff output into hunk structures -function M.parse_diff(diff_text) - local hunks = {} - local current_hunk = nil - local in_hunk = false - - for line in diff_text:gmatch('[^\r\n]+') do - if line:match('^@@') then - -- New hunk header - if current_hunk then - table.insert(hunks, current_hunk) - end - - local old_start, old_count, new_start, new_count = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') - current_hunk = { - old_start = tonumber(old_start), - old_count = tonumber(old_count) or 1, - new_start = tonumber(new_start), - new_count = tonumber(new_count) or 1, - lines = {}, - header = line - } - in_hunk = true - elseif in_hunk and (line:match('^[%+%-]') or line:match('^%s')) then - -- Diff line - table.insert(current_hunk.lines, line) - elseif line:match('^diff %-%-git') or line:match('^index ') or line:match('^%+%+%+ ') or line:match('^%-%-%-') then - -- Skip git diff headers - in_hunk = false - end - end - - -- Add last hunk - if current_hunk then - table.insert(hunks, current_hunk) - end - - return hunks -end - --- Apply visual indicators for diff with line highlights -function M.apply_diff_visualization(bufnr) - local diff_data = M.active_diffs[bufnr] - if not diff_data then return end - - -- Clear existing highlights - vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) - - -- Get current buffer lines for reference - local buf_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - -- Apply highlights for each hunk - for i, hunk in ipairs(diff_data.hunks) do - - -- Track which lines in the current buffer correspond to additions/deletions - local additions = {} - local deletions = {} - - -- Start from the beginning of the hunk and track line numbers - local new_line_num = hunk.new_start -- 1-indexed line number in new file - local old_line_num = hunk.old_start -- 1-indexed line number in old file - - -- First, detect if this hunk is a replacement (has both - and + lines) - local has_deletions = false - local has_additions = false - for _, diff_line in ipairs(hunk.lines) do - if diff_line:match('^%-') then has_deletions = true end - if diff_line:match('^%+') then has_additions = true end - end - local is_replacement = has_deletions and has_additions - - for _, diff_line in ipairs(hunk.lines) do - if diff_line:match('^%+') then - -- This is an added line - it exists in the current buffer at new_line_num - table.insert(additions, new_line_num - 1) -- Convert to 0-indexed for extmarks - new_line_num = new_line_num + 1 - -- Don't advance old_line_num for additions - elseif diff_line:match('^%-') then - -- This is a deleted line - show as virtual text above current position - -- For replacements, the deletion should appear above the addition - local del_line = new_line_num - 1 - if is_replacement and #additions > 0 then - -- Place deletion above the first addition - del_line = additions[1] - end - table.insert(deletions, { - line = del_line, -- 0-indexed - text = diff_line:sub(2), - }) - old_line_num = old_line_num + 1 - -- Don't advance new_line_num for deletions - elseif diff_line:match('^%s') then - -- Context line - advance both - new_line_num = new_line_num + 1 - old_line_num = old_line_num + 1 - end - end - - -- Apply highlighting for additions - for _, line_idx in ipairs(additions) do - if line_idx >= 0 and line_idx < #buf_lines then - vim.api.nvim_buf_set_extmark(bufnr, ns_id, line_idx, 0, { - line_hl_group = 'DiffAdd', - id = 4000 + i * 1000 + line_idx - }) - else - vim.notify('Line ' .. line_idx .. ' out of range (buf has ' .. #buf_lines .. ' lines)', vim.log.levels.WARN) - end - end - - -- Show deletions as virtual text above their position with full-width background - for j, del in ipairs(deletions) do - if del.line >= 0 and del.line <= #buf_lines then - -- Calculate full width for the deletion line - local text = '- ' .. del.text - local win_width = vim.api.nvim_win_get_width(0) - local padding = string.rep(' ', math.max(0, win_width - vim.fn.strdisplaywidth(text))) - - vim.api.nvim_buf_set_extmark(bufnr, ns_id, del.line, 0, { - virt_lines = {{ - {text .. padding, 'DiffDelete'} - }}, - virt_lines_above = true, - id = 3000 + i * 100 + j - }) - end - end - - -- Add sign in gutter for hunk (use first addition or deletion line) - local sign_line = nil - if #additions > 0 then - sign_line = additions[1] - elseif #deletions > 0 then - sign_line = deletions[1].line - else - sign_line = hunk.new_start - 1 - end - - local sign_text = '>' - local sign_hl = 'DiffAdd' - - -- If hunk has deletions, use different sign - if #deletions > 0 then - sign_text = '~' - sign_hl = 'DiffChange' - end - - if sign_line and sign_line >= 0 and sign_line < #buf_lines then - vim.api.nvim_buf_set_extmark(bufnr, ns_id, sign_line, 0, { - sign_text = sign_text, - sign_hl_group = sign_hl, - id = 2000 + i - }) - end - - -- Add subtle hunk info at end of first changed line - local info_line = sign_line - if info_line and info_line >= 0 and info_line < #buf_lines then - vim.api.nvim_buf_set_extmark(bufnr, ns_id, info_line, 0, { - virt_text = {{' [Hunk ' .. i .. '/' .. #diff_data.hunks .. ']', 'Comment'}}, - virt_text_pos = 'eol', - id = 1000 + i - }) - end - end -end - --- Set up buffer-local keymaps for inline diff -function M.setup_inline_keymaps(bufnr) - local opts = { buffer = bufnr, silent = true } - - -- Navigation between hunks - vim.keymap.set('n', ']h', function() M.next_hunk(bufnr) end, - vim.tbl_extend('force', opts, { desc = 'Next Claude hunk' })) - vim.keymap.set('n', '[h', function() M.prev_hunk(bufnr) end, - vim.tbl_extend('force', opts, { desc = 'Previous Claude hunk' })) - - -- Accept/Reject - vim.keymap.set('n', 'ia', function() M.accept_current_hunk(bufnr) end, - vim.tbl_extend('force', opts, { desc = 'Accept Claude hunk' })) - vim.keymap.set('n', 'ir', function() M.reject_current_hunk(bufnr) end, - vim.tbl_extend('force', opts, { desc = 'Reject Claude hunk' })) - - -- Accept/Reject all - vim.keymap.set('n', 'iA', function() M.accept_all_hunks(bufnr) end, - vim.tbl_extend('force', opts, { desc = 'Accept all Claude hunks' })) - vim.keymap.set('n', 'iR', function() M.reject_all_hunks(bufnr) end, - vim.tbl_extend('force', opts, { desc = 'Reject all Claude hunks' })) - - -- List files with diffs - vim.keymap.set('n', 'il', function() M.list_diff_files() end, - vim.tbl_extend('force', opts, { desc = 'List files with Claude diffs' })) - - -- Exit inline diff - vim.keymap.set('n', 'iq', function() M.close_inline_diff(bufnr) end, - vim.tbl_extend('force', opts, { desc = 'Close inline diff' })) -end - --- Jump to specific hunk -function M.jump_to_hunk(bufnr, hunk_idx) - local diff_data = M.active_diffs[bufnr] - if not diff_data or not diff_data.hunks[hunk_idx] then return end - - local hunk = diff_data.hunks[hunk_idx] - diff_data.current_hunk = hunk_idx - - -- Find the first actual changed line (addition or deletion) in this hunk - local jump_line = nil - local new_line_num = hunk.new_start -- 1-indexed line number in new file - - for _, diff_line in ipairs(hunk.lines) do - if diff_line:match('^%+') then - -- Found an addition - jump here - jump_line = new_line_num - break - elseif diff_line:match('^%-') then - -- Found a deletion - jump here - jump_line = new_line_num - break - elseif diff_line:match('^%s') then - -- Context line - advance - new_line_num = new_line_num + 1 - end - end - - -- Fallback to hunk start if no changes found - if not jump_line then - jump_line = hunk.new_start - end - - -- Move cursor to the actual changed line (only if we have a valid window) - local win = vim.api.nvim_get_current_win() - if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then - vim.api.nvim_win_set_cursor(win, {jump_line, 0}) - end - - -- Update status - vim.notify(string.format('Hunk %d/%d', hunk_idx, #diff_data.hunks), vim.log.levels.INFO) -end - --- Navigate to next hunk -function M.next_hunk(bufnr) - local diff_data = M.active_diffs[bufnr] - if not diff_data then return end - - local next_idx = diff_data.current_hunk + 1 - if next_idx > #diff_data.hunks then - next_idx = 1 - end - - M.jump_to_hunk(bufnr, next_idx) -end - --- Navigate to previous hunk -function M.prev_hunk(bufnr) - local diff_data = M.active_diffs[bufnr] - if not diff_data then return end - - local prev_idx = diff_data.current_hunk - 1 - if prev_idx < 1 then - prev_idx = #diff_data.hunks - end - - M.jump_to_hunk(bufnr, prev_idx) -end - --- Generate a patch for a single hunk -function M.generate_hunk_patch(hunk, file_path) - local patch_lines = { - string.format("--- a/%s", file_path), - string.format("+++ b/%s", file_path), - hunk.header - } - - -- Add the hunk lines - for _, line in ipairs(hunk.lines) do - table.insert(patch_lines, line) - end - - -- Ensure patch ends with newline - table.insert(patch_lines, "") - - return table.concat(patch_lines, '\n') -end - --- Simplified approach: update baseline in memory only --- The complex git stash approach was causing issues -function M.update_file_baseline(bufnr) - -- Simply update the in-memory baseline to current buffer content - local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local current_content = table.concat(current_lines, '\n') - M.original_content[bufnr] = current_content - - -- Save state for persistence - local persistence = require('nvim-claude.inline-diff-persistence') - if persistence.current_stash_ref then - persistence.save_state({ stash_ref = persistence.current_stash_ref }) - end -end - --- Accept current hunk -function M.accept_current_hunk(bufnr) - local diff_data = M.active_diffs[bufnr] - if not diff_data then return end - - local hunk_idx = diff_data.current_hunk - local hunk = diff_data.hunks[hunk_idx] - if not hunk then return end - - vim.notify(string.format('Accepting hunk %d/%d', hunk_idx, #diff_data.hunks), vim.log.levels.INFO) - - -- Mark this hunk as accepted - diff_data.applied_hunks[hunk_idx] = 'accepted' - - -- Get current buffer content as the new baseline for this file - local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local current_content = table.concat(current_lines, '\n') - - -- Update the in-memory baseline to current content - M.original_content[bufnr] = current_content - - -- Recalculate diff - local new_diff_data = M.compute_diff(current_content, current_content) - - if not new_diff_data or #new_diff_data.hunks == 0 then - -- All changes accepted for this file - vim.notify('All changes accepted. Closing inline diff.', vim.log.levels.INFO) - - -- Remove this file from Claude edited files tracking - local utils = require('nvim-claude.utils') - local hooks = require('nvim-claude.hooks') - local git_root = utils.get_project_root() - local file_path = vim.api.nvim_buf_get_name(bufnr) - local relative_path = file_path:gsub(git_root .. '/', '') - - if hooks.claude_edited_files[relative_path] then - hooks.claude_edited_files[relative_path] = nil - vim.notify('Removed ' .. relative_path .. ' from Claude tracking', vim.log.levels.DEBUG) - end - - M.close_inline_diff(bufnr, true) - else - -- This shouldn't happen when accepting current state - vim.notify('Unexpected diff after accepting hunk', vim.log.levels.WARN) - end -end - --- Reject current hunk -function M.reject_current_hunk(bufnr) - local diff_data = M.active_diffs[bufnr] - if not diff_data then - vim.notify('No diff data for buffer', vim.log.levels.ERROR) - return - end - - local hunk_idx = diff_data.current_hunk - local hunk = diff_data.hunks[hunk_idx] - if not hunk then - vim.notify('No hunk at index ' .. tostring(hunk_idx), vim.log.levels.ERROR) - return - end - - vim.notify(string.format('Rejecting hunk %d/%d', hunk_idx, #diff_data.hunks), vim.log.levels.INFO) - - -- For reject, apply the patch in reverse to the current file - -- The baseline stays unchanged - local utils = require('nvim-claude.utils') - local git_root = utils.get_project_root() - local file_path = vim.api.nvim_buf_get_name(bufnr) - local relative_path = file_path:gsub(git_root .. '/', '') - - -- Generate patch for this hunk - local patch = M.generate_hunk_patch(hunk, relative_path) - local patch_file = vim.fn.tempname() .. '.patch' - utils.write_file(patch_file, patch) - - -- Debug: Show hunk details - vim.notify(string.format('Hunk %d: old_start=%d, new_start=%d, lines=%d', - hunk_idx, hunk.old_start, hunk.new_start, #hunk.lines), vim.log.levels.INFO) - - -- Debug: save patch for inspection - local debug_file = '/tmp/nvim-claude-reject-patch.txt' - utils.write_file(debug_file, patch) - - -- Apply reverse patch to the working directory - local apply_cmd = string.format('cd "%s" && git apply --reverse --verbose "%s" 2>&1', git_root, patch_file) - local result, err = utils.exec(apply_cmd) - - if err or (result and result:match('error:')) then - vim.notify('Failed to reject hunk: ' .. (err or result), vim.log.levels.ERROR) - vim.notify('Patch saved to: ' .. debug_file, vim.log.levels.INFO) - vim.fn.delete(patch_file) - return - end - - vim.fn.delete(patch_file) - - -- Reload the buffer - vim.api.nvim_buf_call(bufnr, function() - vim.cmd('checktime') - end) - - -- Recalculate diff against unchanged baseline - local hooks = require('nvim-claude.hooks') - - -- Get the new baseline content - local stash_ref = hooks.stable_baseline_ref - local baseline_cmd = string.format('cd "%s" && git show %s:%s 2>/dev/null', git_root, stash_ref, relative_path) - local new_baseline = utils.exec(baseline_cmd) - - if new_baseline then - M.original_content[bufnr] = new_baseline - - -- Get current content - local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local current_content = table.concat(current_lines, '\n') - - -- Recalculate diff - local new_diff_data = M.compute_diff(new_baseline, current_content) - - if not new_diff_data or #new_diff_data.hunks == 0 then - vim.notify('All changes processed. Closing inline diff.', vim.log.levels.INFO) - M.close_inline_diff(bufnr, false) - else - -- Update diff data - diff_data.hunks = new_diff_data.hunks - diff_data.current_hunk = 1 - - -- Refresh visualization - M.apply_diff_visualization(bufnr) - M.jump_to_hunk(bufnr, 1) - vim.notify(string.format('%d hunks remaining', #new_diff_data.hunks), vim.log.levels.INFO) - end - end -end - --- Revert hunk changes (restore original content) -function M.revert_hunk_changes(bufnr, hunk) - -- Get current buffer lines - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - -- Extract the expected content from the hunk - local expected_lines = {} - local original_lines = {} - - for _, diff_line in ipairs(hunk.lines) do - if diff_line:match('^%+') then - -- Lines that were added (these should be in current buffer) - table.insert(expected_lines, diff_line:sub(2)) - elseif diff_line:match('^%-') then - -- Lines that were removed (these should be restored) - table.insert(original_lines, diff_line:sub(2)) - elseif diff_line:match('^%s') then - -- Context lines (should be in both) - table.insert(expected_lines, diff_line:sub(2)) - table.insert(original_lines, diff_line:sub(2)) - end - end - - -- Find where this hunk actually is in the current buffer - -- We'll look for the best match by checking context lines too - local hunk_start = nil - local hunk_end = nil - local best_score = -1 - local best_start = nil - - -- Include some context before and after for better matching - local context_before = {} - local context_after = {} - - -- Extract context from the diff - local in_changes = false - for i, diff_line in ipairs(hunk.lines) do - if diff_line:match('^[%+%-]') then - in_changes = true - elseif diff_line:match('^%s') and not in_changes then - -- Context before changes - table.insert(context_before, diff_line:sub(2)) - elseif diff_line:match('^%s') and in_changes then - -- Context after changes - table.insert(context_after, diff_line:sub(2)) - end - end - - -- Search for the hunk by matching content with context - for i = 1, #lines - #expected_lines + 1 do - local score = 0 - local matches = true - - -- Check the main content - for j = 1, #expected_lines do - if lines[i + j - 1] == expected_lines[j] then - score = score + 1 - else - matches = false - end - end - - if matches then - -- Bonus points for matching context before - local before_start = i - #context_before - if before_start > 0 then - for j = 1, #context_before do - if lines[before_start + j - 1] == context_before[j] then - score = score + 2 -- Context is worth more - end - end - end - - -- Bonus points for matching context after - local after_start = i + #expected_lines - if after_start + #context_after - 1 <= #lines then - for j = 1, #context_after do - if lines[after_start + j - 1] == context_after[j] then - score = score + 2 -- Context is worth more - end - end - end - - -- Keep the best match - if score > best_score then - best_score = score - best_start = i - end - end - end - - if best_start then - hunk_start = best_start - hunk_end = best_start + #expected_lines - 1 - else - vim.notify('Could not find hunk in current buffer - content may have changed', vim.log.levels.ERROR) - return - end - - -- Build new buffer content - local new_lines = {} - - -- Copy lines before the hunk - for i = 1, hunk_start - 1 do - table.insert(new_lines, lines[i]) - end - - -- Insert the original lines - for _, line in ipairs(original_lines) do - table.insert(new_lines, line) - end - - -- Copy lines after the hunk - for i = hunk_end + 1, #lines do - table.insert(new_lines, lines[i]) - end - - -- Update buffer - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines) -end - --- Apply hunk changes to buffer -function M.apply_hunk_changes(bufnr, hunk) - -- Get current buffer lines - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - -- Build new lines with hunk applied - local new_lines = {} - local buffer_line = 1 - local hunk_line = 1 - local applied = false - - while buffer_line <= #lines do - if buffer_line == hunk.old_start and not applied then - -- Apply hunk here - for _, diff_line in ipairs(hunk.lines) do - if diff_line:match('^%+') then - -- Add new line - table.insert(new_lines, diff_line:sub(2)) - elseif diff_line:match('^%-') then - -- Skip deleted line - buffer_line = buffer_line + 1 - else - -- Keep context line - table.insert(new_lines, lines[buffer_line]) - buffer_line = buffer_line + 1 - end - end - applied = true - else - -- Copy unchanged line - if buffer_line <= #lines then - table.insert(new_lines, lines[buffer_line]) - end - buffer_line = buffer_line + 1 - end - end - - -- Update buffer - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines) -end - --- Accept all hunks -function M.accept_all_hunks(bufnr) - local diff_data = M.active_diffs[bufnr] - if not diff_data then return end - - -- Get current buffer content as the new baseline - local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local current_content = table.concat(current_lines, '\n') - - -- Update the in-memory baseline to current content - M.original_content[bufnr] = current_content - - -- Remove this file from Claude edited files tracking - local utils = require('nvim-claude.utils') - local hooks = require('nvim-claude.hooks') - local git_root = utils.get_project_root() - local file_path = vim.api.nvim_buf_get_name(bufnr) - local relative_path = file_path:gsub(git_root .. '/', '') - - if hooks.claude_edited_files[relative_path] then - hooks.claude_edited_files[relative_path] = nil - vim.notify('Removed ' .. relative_path .. ' from Claude tracking', vim.log.levels.DEBUG) - end - - vim.notify('Accepted all Claude changes', vim.log.levels.INFO) - - -- Close inline diff - M.close_inline_diff(bufnr) -end - --- Reject all hunks -function M.reject_all_hunks(bufnr) - vim.notify('Rejected all Claude changes', vim.log.levels.INFO) - - -- Close inline diff - M.close_inline_diff(bufnr) -end - --- Close inline diff mode -function M.close_inline_diff(bufnr, keep_baseline) - -- Clear highlights and virtual text - vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) - - -- Remove buffer-local keymaps - pcall(vim.keymap.del, 'n', ']h', { buffer = bufnr }) - pcall(vim.keymap.del, 'n', '[h', { buffer = bufnr }) - pcall(vim.keymap.del, 'n', 'ia', { buffer = bufnr }) - pcall(vim.keymap.del, 'n', 'ir', { buffer = bufnr }) - pcall(vim.keymap.del, 'n', 'iA', { buffer = bufnr }) - pcall(vim.keymap.del, 'n', 'iR', { buffer = bufnr }) - pcall(vim.keymap.del, 'n', 'il', { buffer = bufnr }) - pcall(vim.keymap.del, 'n', 'iq', { buffer = bufnr }) - - -- Clean up state - M.active_diffs[bufnr] = nil - - -- Remove from diff files tracking - local file_path = vim.api.nvim_buf_get_name(bufnr) - if file_path and M.diff_files[file_path] then - M.diff_files[file_path] = nil - end - - -- Clear the in-memory baseline - M.original_content[bufnr] = nil - - -- Check if all diffs are closed - local has_active_diffs = false - for _, diff in pairs(M.active_diffs) do - if diff then - has_active_diffs = true - break - end - end - - -- Also check if there are still Claude-edited files that haven't been opened yet - local hooks = require('nvim-claude.hooks') - local has_tracked_files = false - for _, tracked in pairs(hooks.claude_edited_files) do - if tracked then - has_tracked_files = true - break - end - end - - -- Only clear everything if no active diffs AND no tracked files - if not has_active_diffs and not has_tracked_files then - local persistence = require('nvim-claude.inline-diff-persistence') - persistence.clear_state() - persistence.current_stash_ref = nil - - -- Reset the stable baseline in hooks - hooks.stable_baseline_ref = nil - hooks.claude_edited_files = {} - end - - vim.notify('Inline diff closed', vim.log.levels.INFO) -end - --- Check if buffer has active inline diff -function M.has_active_diff(bufnr) - return M.active_diffs[bufnr] ~= nil -end - --- Update baseline content after accepting a hunk (deprecated - no longer creates commits) -function M.update_baseline_after_accept(bufnr, hunk) - -- This function is deprecated but kept for compatibility - -- The baseline update is now handled directly in accept_current_hunk - vim.notify('update_baseline_after_accept is deprecated', vim.log.levels.DEBUG) -end - --- Test keymap functionality -function M.test_keymap() - local bufnr = vim.api.nvim_get_current_buf() - vim.notify('Testing keymap for buffer: ' .. bufnr, vim.log.levels.INFO) - vim.notify('Available diff data: ' .. vim.inspect(vim.tbl_keys(M.active_diffs)), vim.log.levels.INFO) - - if M.active_diffs[bufnr] then - vim.notify('Diff data found! Calling reject function...', vim.log.levels.INFO) - M.reject_current_hunk(bufnr) - else - vim.notify('No diff data for this buffer', vim.log.levels.ERROR) - end -end - --- Navigate to next file with diff -function M.next_diff_file() - local current_file = vim.api.nvim_buf_get_name(0) - local files_with_diffs = {} - local hooks = require('nvim-claude.hooks') - - -- Collect all files with diffs (both opened and unopened) - local utils = require('nvim-claude.utils') - local git_root = utils.get_project_root() - - for file_path, bufnr in pairs(M.diff_files) do - local git_relative = file_path:gsub('^' .. vim.pesc(git_root) .. '/', '') - if hooks.claude_edited_files[git_relative] then - table.insert(files_with_diffs, file_path) - end - end - - if #files_with_diffs == 0 then - vim.notify('No files with active diffs', vim.log.levels.INFO) - return - end - - -- Sort files for consistent navigation - table.sort(files_with_diffs) - - -- Find current file index - local current_idx = 0 - for i, file_path in ipairs(files_with_diffs) do - if file_path == current_file then - current_idx = i - break - end - end - - -- Go to next file (wrap around) - local next_idx = current_idx + 1 - if next_idx > #files_with_diffs then - next_idx = 1 - end - - local next_file = files_with_diffs[next_idx] - vim.cmd('edit ' .. vim.fn.fnameescape(next_file)) - vim.notify(string.format('Diff file %d/%d: %s', next_idx, #files_with_diffs, vim.fn.fnamemodify(next_file, ':t')), vim.log.levels.INFO) -end - --- Navigate to previous file with diff -function M.prev_diff_file() - local current_file = vim.api.nvim_buf_get_name(0) - local files_with_diffs = {} - local hooks = require('nvim-claude.hooks') - - -- Collect all files with diffs (both opened and unopened) - local utils = require('nvim-claude.utils') - local git_root = utils.get_project_root() - - for file_path, bufnr in pairs(M.diff_files) do - local git_relative = file_path:gsub('^' .. vim.pesc(git_root) .. '/', '') - if hooks.claude_edited_files[git_relative] then - table.insert(files_with_diffs, file_path) - end - end - - if #files_with_diffs == 0 then - vim.notify('No files with active diffs', vim.log.levels.INFO) - return - end - - -- Sort files for consistent navigation - table.sort(files_with_diffs) - - -- Find current file index - local current_idx = 0 - for i, file_path in ipairs(files_with_diffs) do - if file_path == current_file then - current_idx = i - break - end - end - - -- Go to previous file (wrap around) - local prev_idx = current_idx - 1 - if prev_idx < 1 then - prev_idx = #files_with_diffs - end - - local prev_file = files_with_diffs[prev_idx] - vim.cmd('edit ' .. vim.fn.fnameescape(prev_file)) - vim.notify(string.format('Diff file %d/%d: %s', prev_idx, #files_with_diffs, vim.fn.fnamemodify(prev_file, ':t')), vim.log.levels.INFO) -end - --- List all files with active diffs -function M.list_diff_files() - local files_with_diffs = {} - local hooks = require('nvim-claude.hooks') - - for file_path, bufnr in pairs(M.diff_files) do - -- Check if we have active diffs for this buffer, or if it's a tracked file not yet opened - if (bufnr > 0 and M.active_diffs[bufnr]) or bufnr == -1 then - local diff_data = bufnr > 0 and M.active_diffs[bufnr] or nil - local relative_path = vim.fn.fnamemodify(file_path, ':~:.') - - -- Check if this file is still tracked as Claude-edited - local utils = require('nvim-claude.utils') - local git_root = utils.get_project_root() - local git_relative = file_path:gsub('^' .. vim.pesc(git_root) .. '/', '') - if hooks.claude_edited_files[git_relative] then - table.insert(files_with_diffs, { - path = file_path, - hunks = diff_data and #diff_data.hunks or '?', - name = vim.fn.fnamemodify(file_path, ':t'), - relative_path = relative_path, - current_hunk = diff_data and diff_data.current_hunk or 1 - }) - end - end - end - - if #files_with_diffs == 0 then - vim.notify('No files with active diffs', vim.log.levels.INFO) - return - end - - -- Sort by filename - table.sort(files_with_diffs, function(a, b) return a.name < b.name end) - - -- Create items for vim.ui.select - local items = {} - local display_items = {} - - for i, file_info in ipairs(files_with_diffs) do - table.insert(items, file_info) - local hunk_info = type(file_info.hunks) == 'number' - and string.format('%d hunks, on hunk %d', file_info.hunks, file_info.current_hunk) - or 'not opened yet' - table.insert(display_items, string.format('%s (%s)', - file_info.relative_path, hunk_info)) - end - - -- Use vim.ui.select for a telescope-like experience - vim.ui.select(display_items, { - prompt = 'Select file with Claude edits:', - format_item = function(item) return item end, - }, function(choice, idx) - if choice and idx then - local selected_file = items[idx] - vim.cmd('edit ' .. vim.fn.fnameescape(selected_file.path)) - - -- Jump to the current hunk in the selected file - local bufnr = M.diff_files[selected_file.path] - if bufnr and M.active_diffs[bufnr] then - M.jump_to_hunk(bufnr, M.active_diffs[bufnr].current_hunk) - end - end - end) -end - --- Accept all diffs across all files -function M.accept_all_files() - local hooks = require('nvim-claude.hooks') - local persistence = require('nvim-claude.inline-diff-persistence') - - -- Count tracked files for reporting - local cleared_count = vim.tbl_count(hooks.claude_edited_files) - - if cleared_count == 0 then - vim.notify('No Claude edits to accept', vim.log.levels.INFO) - return - end - - -- Clear all visual diff displays from all buffers - for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(bufnr) and vim.api.nvim_buf_is_loaded(bufnr) then - vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) - end - end - - -- Clear all diff state - M.diff_files = {} - M.original_content = {} - M.active_diffs = {} - - -- Clear all tracking - hooks.claude_edited_files = {} - hooks.stable_baseline_ref = nil - - -- Clear persistence - persistence.clear_state() - - vim.notify(string.format('Accepted all changes from %d files', cleared_count), vim.log.levels.INFO) -end - -return M \ No newline at end of file diff --git a/lua/nvim-claude/mappings.lua b/lua/nvim-claude/mappings.lua deleted file mode 100644 index 1371418a..00000000 --- a/lua/nvim-claude/mappings.lua +++ /dev/null @@ -1,113 +0,0 @@ --- Keybinding mappings for nvim-claude -local M = {} - -function M.setup(config, commands) - local prefix = config.prefix or 'c' - - -- Basic commands - vim.keymap.set('n', prefix .. 'c', ':ClaudeChat', { - desc = 'Open Claude chat', - silent = true - }) - - vim.keymap.set('n', prefix .. 's', ':ClaudeSendBuffer', { - desc = 'Send buffer to Claude', - silent = true - }) - - vim.keymap.set('v', prefix .. 'v', ':ClaudeSendSelection', { - desc = 'Send selection to Claude', - silent = true - }) - - vim.keymap.set('n', prefix .. 'h', ':ClaudeSendHunk', { - desc = 'Send git hunk to Claude', - silent = true - }) - - vim.keymap.set('n', prefix .. 'b', ':ClaudeBg ', { - desc = 'Start background agent', - silent = false -- Allow user to type the task - }) - - vim.keymap.set('n', prefix .. 'l', ':ClaudeAgents', { - desc = 'List agents', - silent = true - }) - - vim.keymap.set('n', prefix .. 'k', ':ClaudeKill', { - desc = 'Kill agent', - silent = true - }) - - vim.keymap.set('n', prefix .. 'x', ':ClaudeClean', { - desc = 'Clean old agents', - silent = true - }) - - -- Register with which-key if available - local ok, which_key = pcall(require, 'which-key') - if ok then - which_key.register({ - [prefix] = { - name = 'Claude', - c = { 'Chat' }, - s = { 'Send Buffer' }, - v = { 'Send Selection' }, - h = { 'Send Git Hunk' }, - b = { 'Background Agent' }, - l = { 'List Agents' }, - k = { 'Kill Agent' }, - x = { 'Clean Old Agents' }, - i = { 'List files with diffs' }, - }, - ['i'] = { - name = 'Inline Diffs', - a = { 'Accept current hunk' }, - r = { 'Reject current hunk' }, - A = { 'Accept all hunks in file' }, - R = { 'Reject all hunks in file' }, - AA = { 'Accept ALL diffs in ALL files' }, - q = { 'Close inline diff' }, - l = { 'List files with diffs' }, - } - }) - end - - -- Global keymaps for navigating between files with Claude diffs - vim.keymap.set('n', ']f', function() - local inline_diff = require('nvim-claude.inline-diff') - inline_diff.next_diff_file() - end, { - desc = 'Next file with Claude diff', - silent = true - }) - - vim.keymap.set('n', '[f', function() - local inline_diff = require('nvim-claude.inline-diff') - inline_diff.prev_diff_file() - end, { - desc = 'Previous file with Claude diff', - silent = true - }) - - -- Global keymap for listing files with diffs - vim.keymap.set('n', prefix .. 'i', function() - local inline_diff = require('nvim-claude.inline-diff') - inline_diff.list_diff_files() - end, { - desc = 'List files with Claude diffs', - silent = true - }) - - -- Global keymap to accept all diffs across all files - vim.keymap.set('n', 'iAA', function() - local inline_diff = require('nvim-claude.inline-diff') - inline_diff.accept_all_files() - end, { - desc = 'Accept ALL Claude diffs in ALL files', - silent = true - }) -end - -return M \ No newline at end of file diff --git a/lua/nvim-claude/registry.lua b/lua/nvim-claude/registry.lua deleted file mode 100644 index 9dfc8264..00000000 --- a/lua/nvim-claude/registry.lua +++ /dev/null @@ -1,248 +0,0 @@ --- Agent registry module for nvim-claude -local M = {} -local utils = require('nvim-claude.utils') - --- Registry data -M.agents = {} -M.registry_path = nil - --- Initialize registry -function M.setup(config) - -- Set up registry directory - local data_dir = vim.fn.stdpath('data') .. '/nvim-claude' - utils.ensure_dir(data_dir) - - M.registry_path = data_dir .. '/registry.json' - - -- Load existing registry - M.load() -end - --- Load registry from disk -function M.load() - vim.notify('registry.load() called', vim.log.levels.DEBUG) - local content = utils.read_file(M.registry_path) - if content then - vim.notify(string.format('registry.load: Read %d bytes from %s', #content, M.registry_path), vim.log.levels.DEBUG) - local ok, data = pcall(vim.json.decode, content) - if ok and type(data) == 'table' then - local agent_count = vim.tbl_count(data) - vim.notify(string.format('registry.load: Decoded %d agents from JSON', agent_count), vim.log.levels.DEBUG) - M.agents = data - M.validate_agents() - else - vim.notify('registry.load: Failed to decode JSON, clearing agents', vim.log.levels.WARN) - M.agents = {} - end - else - vim.notify('registry.load: No content read from file, clearing agents', vim.log.levels.WARN) - M.agents = {} - end -end - --- Save registry to disk -function M.save() - if not M.registry_path then return false end - - local content = vim.json.encode(M.agents) - return utils.write_file(M.registry_path, content) -end - --- Validate agents (remove stale entries) -function M.validate_agents() - local valid_agents = {} - local now = os.time() - - - for id, agent in pairs(M.agents) do - -- Check if agent directory still exists - local mission_log_path = agent.work_dir .. '/mission.log' - local mission_exists = utils.file_exists(mission_log_path) - - - if mission_exists then - -- Check if tmux window still exists - local window_exists = M.check_window_exists(agent.window_id) - - if window_exists then - agent.status = 'active' - - -- Update progress from file for active agents - local progress_file = agent.work_dir .. '/progress.txt' - local progress_content = utils.read_file(progress_file) - if progress_content and progress_content ~= '' then - agent.progress = progress_content:gsub('\n$', '') -- Remove trailing newline - end - - valid_agents[id] = agent - else - -- Window closed, mark as completed - agent.status = 'completed' - agent.end_time = agent.end_time or now - valid_agents[id] = agent - end - end - end - - M.agents = valid_agents - M.save() -end - --- Check if tmux window exists -function M.check_window_exists(window_id) - if not window_id then return false end - - local cmd = string.format("tmux list-windows -F '#{window_id}' | grep -q '^%s$'", window_id) - local result = os.execute(cmd) - return result == 0 -end - --- Register a new agent -function M.register(task, work_dir, window_id, window_name, fork_info) - local id = utils.timestamp() .. '-' .. math.random(1000, 9999) - local agent = { - id = id, - task = task, - work_dir = work_dir, - window_id = window_id, - window_name = window_name, - start_time = os.time(), - status = 'active', - project_root = utils.get_project_root(), - progress = 'Starting...', -- Add progress field - last_update = os.time(), - fork_info = fork_info, -- Store branch/stash info - } - - M.agents[id] = agent - M.save() - - return id -end - --- Get agent by ID -function M.get(id) - return M.agents[id] -end - --- Get all agents for current project -function M.get_project_agents() - -- Ensure registry is loaded - if not M.agents or vim.tbl_isempty(M.agents) then - M.load() - end - - local project_root = utils.get_project_root() - local project_agents = {} - - for id, agent in pairs(M.agents) do - if agent.project_root == project_root then - -- Include the registry ID with the agent - agent._registry_id = id - table.insert(project_agents, agent) - end - end - - return project_agents -end - --- Get active agents count -function M.get_active_count() - local count = 0 - for _, agent in pairs(M.agents) do - if agent.status == 'active' then - count = count + 1 - end - end - return count -end - --- Update agent status -function M.update_status(id, status) - if M.agents[id] then - M.agents[id].status = status - if status == 'completed' or status == 'failed' then - M.agents[id].end_time = os.time() - end - M.agents[id].last_update = os.time() - M.save() - end -end - --- Update agent progress -function M.update_progress(id, progress) - if M.agents[id] then - M.agents[id].progress = progress - M.agents[id].last_update = os.time() - M.save() - end -end - --- Remove agent -function M.remove(id) - M.agents[id] = nil - M.save() -end - --- Clean up old agents -function M.cleanup(days) - if not days or days < 0 then return end - - local cutoff = os.time() - (days * 24 * 60 * 60) - local removed = 0 - - for id, agent in pairs(M.agents) do - if agent.status ~= 'active' and agent.end_time and agent.end_time < cutoff then - -- Remove work directory - if agent.work_dir and utils.file_exists(agent.work_dir) then - local cmd = string.format('rm -rf "%s"', agent.work_dir) - utils.exec(cmd) - end - - M.agents[id] = nil - removed = removed + 1 - end - end - - if removed > 0 then - M.save() - end - - return removed -end - --- Format agent for display -function M.format_agent(agent) - local age = os.difftime(os.time(), agent.start_time) - local age_str - - if age < 60 then - age_str = string.format('%ds', age) - elseif age < 3600 then - age_str = string.format('%dm', math.floor(age / 60)) - elseif age < 86400 then - age_str = string.format('%dh', math.floor(age / 3600)) - else - age_str = string.format('%dd', math.floor(age / 86400)) - end - - local progress_str = '' - if agent.progress and agent.status == 'active' then - progress_str = string.format(' | %s', agent.progress) - end - - -- Clean up task to single line - local task_line = agent.task:match('[^\n]*') or agent.task - local task_preview = task_line:sub(1, 50) .. (task_line:len() > 50 and '...' or '') - - return string.format( - '[%s] %s (%s) - %s%s', - agent.status:upper(), - task_preview, - age_str, - agent.window_name or 'unknown', - progress_str - ) -end - -return M \ No newline at end of file diff --git a/lua/nvim-claude/settings-updater.lua b/lua/nvim-claude/settings-updater.lua deleted file mode 100644 index 6ba88229..00000000 --- a/lua/nvim-claude/settings-updater.lua +++ /dev/null @@ -1,112 +0,0 @@ -local M = {} -local utils = require('nvim-claude.utils') - --- Update Claude settings with current Neovim server address -function M.update_claude_settings() - local project_root = utils.get_project_root() - if not project_root then - return - end - - local settings_path = project_root .. '/.claude/settings.json' - local settings_dir = project_root .. '/.claude' - - -- Get current Neovim server address - local server_addr = vim.v.servername - if not server_addr or server_addr == '' then - -- If no servername, we can't communicate - return - end - - -- Ensure .claude directory exists - if vim.fn.isdirectory(settings_dir) == 0 then - vim.fn.mkdir(settings_dir, 'p') - end - - -- Read existing settings or create new - local settings = {} - if vim.fn.filereadable(settings_path) == 1 then - local ok, content = pcall(vim.fn.readfile, settings_path) - if ok and #content > 0 then - local decode_ok, decoded = pcall(vim.json.decode, table.concat(content, '\n')) - if decode_ok then - settings = decoded - end - end - end - - -- Ensure hooks structure exists - if not settings.hooks then - settings.hooks = {} - end - if not settings.hooks.PreToolUse then - settings.hooks.PreToolUse = {} - end - if not settings.hooks.PostToolUse then - settings.hooks.PostToolUse = {} - end - - -- Update hook commands with current server address - local pre_hook_cmd = string.format( - 'nvr --servername "%s" --remote-expr \'luaeval("require(\\"nvim-claude.hooks\\").pre_tool_use_hook()")\'', - server_addr - ) - - local post_hook_cmd = string.format( - 'nvr --servername "%s" --remote-send ":lua require(\'nvim-claude.hooks\').post_tool_use_hook()"', - server_addr - ) - - -- Update PreToolUse hooks - local pre_hook_found = false - for _, hook_group in ipairs(settings.hooks.PreToolUse) do - if hook_group.matcher == "Edit|Write|MultiEdit" then - hook_group.hooks = {{type = "command", command = pre_hook_cmd}} - pre_hook_found = true - break - end - end - - if not pre_hook_found then - table.insert(settings.hooks.PreToolUse, { - matcher = "Edit|Write|MultiEdit", - hooks = {{type = "command", command = pre_hook_cmd}} - }) - end - - -- Update PostToolUse hooks - local post_hook_found = false - for _, hook_group in ipairs(settings.hooks.PostToolUse) do - if hook_group.matcher == "Edit|Write|MultiEdit" then - hook_group.hooks = {{type = "command", command = post_hook_cmd}} - post_hook_found = true - break - end - end - - if not post_hook_found then - table.insert(settings.hooks.PostToolUse, { - matcher = "Edit|Write|MultiEdit", - hooks = {{type = "command", command = post_hook_cmd}} - }) - end - - -- Write updated settings - local encoded = vim.json.encode(settings) - vim.fn.writefile({encoded}, settings_path) -end - --- Setup autocmds to update settings -function M.setup() - vim.api.nvim_create_autocmd({"VimEnter", "DirChanged"}, { - group = vim.api.nvim_create_augroup("NvimClaudeSettingsUpdater", { clear = true }), - callback = function() - -- Defer to ensure servername is available - vim.defer_fn(function() - M.update_claude_settings() - end, 100) - end, - }) -end - -return M \ No newline at end of file diff --git a/lua/nvim-claude/statusline.lua b/lua/nvim-claude/statusline.lua deleted file mode 100644 index 3879c350..00000000 --- a/lua/nvim-claude/statusline.lua +++ /dev/null @@ -1,67 +0,0 @@ --- Statusline components for nvim-claude -local M = {} - --- Get active agent count and summary -function M.get_agent_status() - local registry = require('nvim-claude.registry') - - -- Validate agents to update their status - registry.validate_agents() - - local agents = registry.get_project_agents() - local active_count = 0 - local latest_progress = nil - local latest_task = nil - - for _, agent in ipairs(agents) do - if agent.status == 'active' then - active_count = active_count + 1 - -- Get the most recently updated active agent - if not latest_progress or (agent.last_update and agent.last_update > (latest_progress.last_update or 0)) then - latest_progress = agent.progress - latest_task = agent.task - end - end - end - - if active_count == 0 then - return '' - elseif active_count == 1 and latest_progress then - -- Show single agent progress - local task_short = latest_task - if #latest_task > 20 then - task_short = latest_task:sub(1, 17) .. '...' - end - return string.format('🤖 %s: %s', task_short, latest_progress) - else - -- Show count of multiple agents - return string.format('🤖 %d agents', active_count) - end -end - --- Lualine component -function M.lualine_component() - return { - M.get_agent_status, - cond = function() - -- Only show if there are active agents - local status = M.get_agent_status() - return status ~= '' - end, - on_click = function() - -- Open agent list on click - vim.cmd('ClaudeAgents') - end, - } -end - --- Simple string function for custom statuslines -function M.statusline() - local status = M.get_agent_status() - if status ~= '' then - return ' ' .. status .. ' ' - end - return '' -end - -return M \ No newline at end of file diff --git a/lua/nvim-claude/tmux.lua b/lua/nvim-claude/tmux.lua deleted file mode 100644 index d225b7a8..00000000 --- a/lua/nvim-claude/tmux.lua +++ /dev/null @@ -1,271 +0,0 @@ --- Tmux interaction module for nvim-claude -local M = {} -local utils = require('nvim-claude.utils') - -M.config = {} - -function M.setup(config) - M.config = config or {} -end - --- Find Claude pane by checking running command -function M.find_claude_pane() - -- Get list of panes with their PIDs and current command - local cmd = "tmux list-panes -F '#{pane_id}:#{pane_pid}:#{pane_title}:#{pane_current_command}'" - local result = utils.exec(cmd) - - if result and result ~= '' then - -- Check each pane - for line in result:gmatch('[^\n]+') do - local pane_id, pane_pid, pane_title, pane_cmd = line:match('^([^:]+):([^:]+):([^:]*):(.*)$') - if pane_id and pane_pid then - -- Check by title first (our created panes) - if pane_title and pane_title == (M.config.pane_title or 'claude-chat') then - return pane_id - end - - -- Check if title contains Claude Code indicators (Anthropic symbol, "claude", "Claude") - if pane_title and (pane_title:match('✳') or pane_title:match('[Cc]laude')) then - return pane_id - end - - -- Check if current command is claude-related - if pane_cmd and pane_cmd:match('claude') then - return pane_id - end - - -- Check if any child process of this pane is running claude or claude-code - -- This handles cases where claude is run under a shell - local check_cmd = string.format( - "ps -ef | awk '$3 == %s' | grep -c -E '(claude|claude-code)' 2>/dev/null", - pane_pid - ) - local count_result = utils.exec(check_cmd) - if count_result and tonumber(count_result) and tonumber(count_result) > 0 then - return pane_id - end - end - end - end - - return nil -end - --- Create new tmux pane for Claude (or return existing) -function M.create_pane(command) - local existing = M.find_claude_pane() - if existing then - -- Just select the existing pane, don't create a new one - local _, err = utils.exec('tmux select-pane -t ' .. existing) - if err then - -- Pane might have been closed, continue to create new one - vim.notify('Claude pane no longer exists, creating new one', vim.log.levels.INFO) - else - return existing - end - end - - -- Build size option safely - local size_opt = '' - if M.config.split_size and tonumber(M.config.split_size) then - local size = tonumber(M.config.split_size) - if utils.tmux_supports_length_percent() then - size_opt = '-l ' .. tostring(size) .. '%' - else - size_opt = '-p ' .. tostring(size) - end - end - - local split_cmd = M.config.split_direction == 'v' and 'split-window -v' or 'split-window -h' - - -- Build command parts to avoid double spaces - local parts = { 'tmux', split_cmd } - if size_opt ~= '' then table.insert(parts, size_opt) end - table.insert(parts, '-P') - local cmd = table.concat(parts, ' ') - if command then - cmd = cmd .. " '" .. command .. "'" - end - - local result, err = utils.exec(cmd) - if err or not result or result == '' or result:match('error') then - vim.notify('nvim-claude: tmux split failed: ' .. (err or result or 'unknown'), vim.log.levels.ERROR) - return nil - end - - local pane_id = result:gsub('\n', '') - utils.exec(string.format("tmux select-pane -t %s -T '%s'", pane_id, M.config.pane_title or 'claude-chat')) - - -- Launch command in the new pane if provided - if command and command ~= '' then - M.send_to_pane(pane_id, command) - end - - return pane_id -end - --- Send keys to a pane (single line with Enter) -function M.send_to_pane(pane_id, text) - if not pane_id then return false end - - -- Escape single quotes in text - text = text:gsub("'", "'\"'\"'") - - local cmd = string.format( - "tmux send-keys -t %s '%s' Enter", - pane_id, - text - ) - - local _, err = utils.exec(cmd) - return err == nil -end - --- Send multi-line text to a pane (for batched content) -function M.send_text_to_pane(pane_id, text) - if not pane_id then return false end - - -- Create a temporary file to hold the text - local tmpfile = os.tmpname() - local file = io.open(tmpfile, 'w') - if not file then - vim.notify('Failed to create temporary file for text', vim.log.levels.ERROR) - return false - end - - file:write(text) - file:close() - - -- Use tmux load-buffer and paste-buffer to send the content - local cmd = string.format( - "tmux load-buffer -t %s '%s' && tmux paste-buffer -t %s && rm '%s'", - pane_id, tmpfile, pane_id, tmpfile - ) - - local _, err = utils.exec(cmd) - if err then - -- Clean up temp file on error - os.remove(tmpfile) - vim.notify('Failed to send text to pane: ' .. err, vim.log.levels.ERROR) - return false - end - - -- Don't send Enter after pasting to avoid submitting the message - -- User can manually submit when ready - - return true -end - --- Create new tmux window for agent -function M.create_agent_window(name, cwd) - local base_cmd = string.format("tmux new-window -n '%s'", name) - if cwd and cwd ~= '' then - base_cmd = base_cmd .. string.format(" -c '%s'", cwd) - end - - local cmd_with_fmt = base_cmd .. " -P -F '#{window_id}'" - - -- Try with -F first (preferred) - local result, err = utils.exec(cmd_with_fmt) - if not err and result and result ~= '' and not result:match('error') then - return result:gsub('\n', '') - end - - -- Fallback: simple -P - local cmd_simple = base_cmd .. ' -P' - result, err = utils.exec(cmd_simple) - if not err and result and result ~= '' then - return result:gsub('\n', '') - end - - vim.notify('nvim-claude: tmux new-window failed: ' .. (err or result or 'unknown'), vim.log.levels.ERROR) - return nil -end - --- Send keys to an entire window (select-pane 0) -function M.send_to_window(window_id, text) - if not window_id then return false end - text = text:gsub("'", "'\"'\"'") - -- Send to the window's active pane (no .0 suffix) - local cmd = string.format("tmux send-keys -t %s '%s' Enter", window_id, text) - local _, err = utils.exec(cmd) - return err == nil -end - --- Switch to window -function M.switch_to_window(window_id) - local cmd = 'tmux select-window -t ' .. window_id - local _, err = utils.exec(cmd) - return err == nil -end - --- Kill pane or window -function M.kill_pane(pane_id) - local cmd = 'tmux kill-pane -t ' .. pane_id - local _, err = utils.exec(cmd) - return err == nil -end - --- Check if tmux is running -function M.is_inside_tmux() - -- Check environment variable first - if os.getenv('TMUX') then - return true - end - - -- Fallback: try to get current session name - local result = utils.exec('tmux display-message -p "#{session_name}" 2>/dev/null') - return result and result ~= '' and not result:match('error') -end - --- Validate tmux availability -function M.validate() - if not utils.has_tmux() then - vim.notify('tmux not found. Please install tmux.', vim.log.levels.ERROR) - return false - end - - if not M.is_inside_tmux() then - vim.notify('Not inside tmux session. Please run nvim inside tmux.', vim.log.levels.ERROR) - return false - end - - return true -end - --- Split a given window and return the new pane id --- direction: 'h' (horizontal/right) or 'v' (vertical/bottom) --- size_percent: number (percentage) -function M.split_window(window_id, direction, size_percent) - direction = direction == 'v' and '-v' or '-h' - - local size_opt = '' - if size_percent and tonumber(size_percent) then - local size = tonumber(size_percent) - if utils.tmux_supports_length_percent() then - size_opt = string.format('-l %s%%', size) - else - size_opt = string.format('-p %s', size) - end - end - - -- Build command - local parts = { - 'tmux', - 'split-window', - direction, - } - if size_opt ~= '' then table.insert(parts, size_opt) end - table.insert(parts, '-P -F "#{pane_id}"') - table.insert(parts, '-t ' .. window_id) - - local cmd = table.concat(parts, ' ') - local pane_id, err = utils.exec(cmd) - if err or not pane_id or pane_id == '' then - vim.notify('nvim-claude: tmux split-window failed: ' .. (err or pane_id or 'unknown'), vim.log.levels.ERROR) - return nil - end - return pane_id:gsub('\n', '') -end - -return M \ No newline at end of file diff --git a/lua/nvim-claude/utils.lua b/lua/nvim-claude/utils.lua deleted file mode 100644 index 838ef425..00000000 --- a/lua/nvim-claude/utils.lua +++ /dev/null @@ -1,151 +0,0 @@ --- Utility functions for nvim-claude --- TEST EDIT #2: Testing multi-file accept -local M = {} - --- Check if we're in a git repository -function M.is_git_repo() - local handle = io.popen('git rev-parse --git-dir 2>/dev/null') - if handle then - local result = handle:read('*a') - handle:close() - return result ~= '' - end - return false -end - --- Get project root (git root or current working directory) -function M.get_project_root() - if M.is_git_repo() then - local handle = io.popen('git rev-parse --show-toplevel 2>/dev/null') - if handle then - local root = handle:read('*a'):gsub('\n', '') - handle:close() - return root - end - end - return vim.fn.getcwd() -end - --- Create directory if it doesn't exist -function M.ensure_dir(path) - local stat = vim.loop.fs_stat(path) - if not stat then - vim.fn.mkdir(path, 'p') - return true - end - return stat.type == 'directory' -end - --- Read file contents -function M.read_file(path) - local file = io.open(path, 'r') - if not file then return nil end - local content = file:read('*a') - file:close() - return content -end - --- Write file contents -function M.write_file(path, content) - local file = io.open(path, 'w') - if not file then return false end - file:write(content) - file:close() - return true -end - --- Check if file exists -function M.file_exists(path) - local stat = vim.loop.fs_stat(path) - return stat ~= nil -end - --- Generate timestamp string -function M.timestamp() - return os.date('%Y-%m-%d-%H%M%S') -end - --- Generate agent directory name -function M.agent_dirname(task) - -- Sanitize task name for filesystem - local safe_task = task:gsub('[^%w%-_]', '-'):gsub('%-+', '-'):sub(1, 50) - return string.format('agent-%s-%s', M.timestamp(), safe_task) -end - --- Execute shell command and return output -function M.exec(cmd) - local handle = io.popen(cmd .. ' 2>&1') - if not handle then return nil, 'Failed to execute command' end - local result = handle:read('*a') - local ok = handle:close() - if ok then - return result, nil - else - return result, result - end -end - --- Check if tmux is available -function M.has_tmux() - local result = M.exec('which tmux') - return result and result:match('/tmux') -end - --- Get current tmux session -function M.get_tmux_session() - local result = M.exec('tmux display-message -p "#{session_name}" 2>/dev/null') - if result and result ~= '' then - return result:gsub('\n', '') - end - return nil -end - --- Get tmux version as number (e.g., 3.4) or 0 if unknown -function M.tmux_version() - local result = M.exec('tmux -V 2>/dev/null') - if not result then return 0 end - -- Expected output: "tmux 3.4" - local ver = result:match('tmux%s+([0-9]+%.[0-9]+)') - return tonumber(ver) or 0 -end - --- Determine if tmux supports the new -l % syntax (>= 3.4) -function M.tmux_supports_length_percent() - return M.tmux_version() >= 3.4 -end - --- Write JSON to file -function M.write_json(path, data) - local success, json = pcall(vim.fn.json_encode, data) - if not success then - return false, 'Failed to encode JSON: ' .. json - end - - local file = io.open(path, 'w') - if not file then - return false, 'Failed to open file for writing: ' .. path - end - - -- Pretty print JSON - local formatted = json:gsub('},{', '},\n {'):gsub('\\{', '{\n '):gsub('\\}', '\n}') - file:write(formatted) - file:close() - return true, nil -end - --- Read JSON from file -function M.read_json(path) - local content = M.read_file(path) - if not content then - return nil, 'Failed to read file: ' .. path - end - - local success, data = pcall(vim.fn.json_decode, content) - if not success then - return nil, 'Failed to decode JSON: ' .. data - end - - return data, nil -end - -return M \ No newline at end of file