From 6f18060a5181b3145d94041e6c868bae7ac9e6e2 Mon Sep 17 00:00:00 2001 From: zolinthecow Date: Fri, 11 Jul 2025 02:53:52 -0700 Subject: [PATCH] agents --- .gitignore | 1 + lua/nvim-claude/commands.lua | 1039 ++++++++++++++++++++++++++++++-- lua/nvim-claude/git.lua | 60 +- lua/nvim-claude/registry.lua | 61 +- lua/nvim-claude/statusline.lua | 67 ++ lua/nvim-claude/utils.lua | 2 +- tasks.md | 38 +- 7 files changed, 1192 insertions(+), 76 deletions(-) create mode 100644 lua/nvim-claude/statusline.lua diff --git a/.gitignore b/.gitignore index c9f810d4..d225e73e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ lazy-lock.json # Claude Code hooks .claude/ +.agent-work/ diff --git a/lua/nvim-claude/commands.lua b/lua/nvim-claude/commands.lua index fbbc4fd0..efed4d03 100644 --- a/lua/nvim-claude/commands.lua +++ b/lua/nvim-claude/commands.lua @@ -42,7 +42,7 @@ function M.setup(claude_module) M.claude_bg(opts.args) end, { desc = 'Start a background Claude agent', - nargs = '+', + nargs = '*', -- Changed from '+' to '*' to allow zero arguments complete = function() return {} end }) @@ -84,6 +84,84 @@ function M.setup(claude_module) 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 @@ -274,14 +352,354 @@ function M.send_hunk() vim.notify('Git hunk sent to Claude', vim.log.levels.INFO) end --- Start background agent +-- Start background agent with UI function M.claude_bg(task) if not claude.tmux.validate() then return end - if not task or task == '' then - vim.notify('Please provide a task description', vim.log.levels.ERROR) + -- 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 @@ -307,30 +725,55 @@ function M.claude_bg(task) return end - -- Create worktree or clone + -- Handle fork options local success, result - if claude.config.agents.use_worktrees and claude.git.supports_worktrees() then - local branch = claude.git.current_branch() or 'main' - success, result = claude.git.create_worktree(agent_dir, branch) - if not success then - vim.notify('Failed to create worktree: ' .. tostring(result), vim.log.levels.ERROR) - return - end - else - -- Fallback to copy (simplified for now) - local cmd = string.format('cp -r "%s"/* "%s"/', project_root, agent_dir) - local _, err = claude.utils.exec(cmd) - if err then - vim.notify('Failed to copy project: ' .. err, vim.log.levels.ERROR) - return + 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 - -- Create mission log + 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\n", + "Agent Mission Log\n================\n\nTask: %s\nStarted: %s\nStatus: Active\n%s\n\n", task, - os.date('%Y-%m-%d %H:%M:%S') + os.date('%Y-%m-%d %H:%M:%S'), + base_info ) claude.utils.write_file(agent_dir .. '/mission.log', log_content) @@ -350,14 +793,25 @@ function M.claude_bg(task) local window_id = claude.tmux.create_agent_window(window_name, agent_dir) if window_id then - -- Register agent - local agent_id = claude.registry.register(task, agent_dir, window_id, window_name) + -- 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 .') @@ -371,25 +825,29 @@ function M.claude_bg(task) 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.", - task, agent_dir + "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', + 'Background agent started\nID: %s\nTask: %s\nWorkspace: %s\nWindow: %s\n%s', agent_id, task, agent_dir, - window_name + 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 +-- List all agents with interactive UI function M.list_agents() -- Validate agents first claude.registry.validate_agents() @@ -400,47 +858,147 @@ function M.list_agents() return end - -- Create a simple list view - local lines = { 'Claude Agents:', '' } + -- Sort agents by start time (newest first) + table.sort(agents, function(a, b) return a.start_time > b.start_time end) - -- Sort agents by start time - local sorted_agents = {} - for id, agent in pairs(agents) do - agent.id = id - table.insert(sorted_agents, agent) - end - table.sort(sorted_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') - for _, agent in ipairs(sorted_agents) do - table.insert(lines, claude.registry.format_agent(agent)) - table.insert(lines, ' ID: ' .. agent.id) - table.insert(lines, ' Window: ' .. (agent.window_name or 'unknown')) + -- 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 - -- Display in a 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 = 60 - local height = math.min(#lines, 20) - local opts = { + -- 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 = (vim.o.columns - width) / 2, - row = (vim.o.lines - height) / 2, + 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', + }) - local win = vim.api.nvim_open_win(buf, true, opts) - vim.api.nvim_win_set_option(win, 'winhl', 'Normal:Normal,FloatBorder:Comment') + -- Set initial cursor position + vim.api.nvim_win_set_cursor(win, {start_line, 0}) - -- 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 }) + -- 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 @@ -653,13 +1211,132 @@ end -- Clean up old agents function M.clean_agents() - local removed = claude.registry.cleanup(claude.config.agents.cleanup_days) + -- 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() - if removed > 0 then - vim.notify(string.format('Cleaned up %d old agent(s)', removed), vim.log.levels.INFO) - else - vim.notify('No old agents to clean up', vim.log.levels.INFO) + 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 @@ -720,4 +1397,246 @@ function M.debug_panes() 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/git.lua b/lua/nvim-claude/git.lua index 66d73555..b0059116 100644 --- a/lua/nvim-claude/git.lua +++ b/lua/nvim-claude/git.lua @@ -44,7 +44,7 @@ end -- Create a new worktree function M.create_worktree(path, branch) - branch = branch or 'main' + branch = branch or M.default_branch() -- Check if worktree already exists local worktrees = M.list_worktrees() @@ -54,22 +54,44 @@ function M.create_worktree(path, branch) end end - -- Create worktree - local cmd = string.format('git worktree add "%s" "%s" 2>&1', path, branch) + -- 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 - return true, { path = path, branch = branch } + -- 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 _, err = utils.exec(cmd) - return err == nil + 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 @@ -103,6 +125,32 @@ function M.current_branch() 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' diff --git a/lua/nvim-claude/registry.lua b/lua/nvim-claude/registry.lua index af8b8fc2..9dfc8264 100644 --- a/lua/nvim-claude/registry.lua +++ b/lua/nvim-claude/registry.lua @@ -20,16 +20,22 @@ 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 @@ -47,14 +53,27 @@ 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 - if utils.file_exists(agent.work_dir .. '/mission.log') then + 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 @@ -79,7 +98,7 @@ function M.check_window_exists(window_id) end -- Register a new agent -function M.register(task, work_dir, window_id, window_name) +function M.register(task, work_dir, window_id, window_name, fork_info) local id = utils.timestamp() .. '-' .. math.random(1000, 9999) local agent = { id = id, @@ -90,6 +109,9 @@ function M.register(task, work_dir, window_id, 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 @@ -105,12 +127,19 @@ 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 - project_agents[id] = agent + -- Include the registry ID with the agent + agent._registry_id = id + table.insert(project_agents, agent) end end @@ -135,6 +164,16 @@ function M.update_status(id, 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 @@ -187,12 +226,22 @@ function M.format_agent(agent) 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] %s (%s) - %s%s', agent.status:upper(), - agent.task, + task_preview, age_str, - agent.window_name or 'unknown' + agent.window_name or 'unknown', + progress_str ) end diff --git a/lua/nvim-claude/statusline.lua b/lua/nvim-claude/statusline.lua new file mode 100644 index 00000000..3879c350 --- /dev/null +++ b/lua/nvim-claude/statusline.lua @@ -0,0 +1,67 @@ +-- 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/utils.lua b/lua/nvim-claude/utils.lua index 6d72f280..838ef425 100644 --- a/lua/nvim-claude/utils.lua +++ b/lua/nvim-claude/utils.lua @@ -57,7 +57,7 @@ end -- Check if file exists function M.file_exists(path) local stat = vim.loop.fs_stat(path) - return stat and stat.type == 'file' + return stat ~= nil end -- Generate timestamp string diff --git a/tasks.md b/tasks.md index ed8ac889..2b57dfec 100644 --- a/tasks.md +++ b/tasks.md @@ -200,8 +200,8 @@ Building a Claude Code integration for Neovim that works seamlessly with a tmux- #### 8.2 Quick Commands - [x] `:ClaudeKill [agent]` - Terminate agent - [x] `:ClaudeClean` - Clean up old agents -- [ ] `:ClaudeSwitch [agent]` - Switch to agent tmux -- [x] `:ClaudeAgents` - List all agents +- [x] `:ClaudeAgents` - Interactive agent manager (switch, diff, kill) +- [x] `:ClaudeDiffAgent` - Review agent changes with diffview - [x] `:ClaudeResetBaseline` - Reset inline diff baseline - [x] Test: Each command functions correctly @@ -316,4 +316,36 @@ Building a Claude Code integration for Neovim that works seamlessly with a tmux- - Improve documentation with examples - Create demo videos showcasing inline diff system - Add support for partial hunk acceptance -- TEST EDIT: Testing single file edit after accept all + +## Background Agent Features (v1.0 Complete) + +### Key Improvements +1. **Hook Isolation**: Background agents always run without hooks (no inline diffs) +2. **ClaudeSwitch**: Switch to agent's worktree to chat/give follow-ups (hooks remain disabled) +3. **ClaudeDiffAgent**: Review agent changes using diffview.nvim +4. **Progress Tracking**: Agents can update progress.txt for real-time status updates +5. **Statusline Integration**: Shows active agent count and latest progress +6. **Enhanced Agent Creation UI**: Interactive popup for mission description with fork options + +### Usage +- `ClaudeBg` - Opens interactive UI for creating agents with: + - Multi-line mission description editor + - Fork options: current branch, main, stash, or any branch + - Shows what the agent will be based on +- `ClaudeBg ` - Quick creation (backwards compatible) +- `ClaudeSwitch [agent]` - Switch to agent's worktree to chat (no inline diffs) +- `ClaudeDiffAgent [agent]` - Review agent changes with diffview +- `ClaudeAgents` - List all agents with progress +- Agents update progress: `echo 'status' > progress.txt` + +### Agent Creation Options +- **Fork from current branch**: Default, uses your current branch state +- **Fork from default branch**: Start fresh from your default branch (auto-detects main/master) +- **Stash current changes**: Creates stash of current work, then applies to agent +- **Fork from other branch**: Choose any branch to base agent on + +### Smart Branch Detection +The plugin automatically detects your repository's default branch (main, master, etc.) instead of assuming "main", making it compatible with older repositories that use "master". + +### Design Philosophy +Background agents are kept simple - they're always background agents with hooks disabled. This avoids complexity around state transitions and keeps the workflow predictable. Use regular `:ClaudeChat` in your main workspace for inline diff functionality.