From 2a71451ca30c42265bbf0e038cff39d07f6adb87 Mon Sep 17 00:00:00 2001 From: zolinthecow Date: Wed, 9 Jul 2025 13:33:29 -0700 Subject: [PATCH] phase 1 mvp --- .gitignore | 5 + check-tasks.sh | 58 +++ claude-integration-dev.md | 74 ++++ lua/colinzhao/lazy/claude.lua | 13 + lua/colinzhao/lazy/init.lua | 3 + lua/nvim-claude/commands.lua | 723 ++++++++++++++++++++++++++++++++++ lua/nvim-claude/git.lua | 138 +++++++ lua/nvim-claude/init.lua | 135 +++++++ lua/nvim-claude/mappings.lua | 67 ++++ lua/nvim-claude/registry.lua | 199 ++++++++++ lua/nvim-claude/tmux.lua | 271 +++++++++++++ lua/nvim-claude/utils.lua | 116 ++++++ tasks.md | 261 ++++++++++++ test-phase1.lua | 228 +++++++++++ test-plugin.sh | 43 ++ 15 files changed, 2334 insertions(+) create mode 100755 check-tasks.sh create mode 100644 claude-integration-dev.md create mode 100644 lua/colinzhao/lazy/claude.lua create mode 100644 lua/nvim-claude/commands.lua create mode 100644 lua/nvim-claude/git.lua create mode 100644 lua/nvim-claude/init.lua create mode 100644 lua/nvim-claude/mappings.lua create mode 100644 lua/nvim-claude/registry.lua create mode 100644 lua/nvim-claude/tmux.lua create mode 100644 lua/nvim-claude/utils.lua create mode 100644 tasks.md create mode 100644 test-phase1.lua create mode 100755 test-plugin.sh diff --git a/.gitignore b/.gitignore index 468fa46b..cfa411de 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,12 @@ test.sh .luarc.json nvim +hihi + spell/ lazy-lock.json **/.DS_Store + +# Development tracking +.agent-work diff --git a/check-tasks.sh b/check-tasks.sh new file mode 100755 index 00000000..8519174f --- /dev/null +++ b/check-tasks.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Quick script to check task progress in tasks.md + +echo "=== Neovim Claude Integration - Task Progress ===" +echo + +# Count total tasks and completed tasks +total=$(grep -c "^\s*- \[" tasks.md) +completed=$(grep -c "^\s*- \[x\]" tasks.md) +percentage=$((completed * 100 / total)) + +echo "Overall Progress: $completed/$total ($percentage%)" +echo + +# Show progress by section +echo "Progress by Feature:" +echo "-------------------" + +current_section="" +section_total=0 +section_completed=0 + +while IFS= read -r line; do + # Check for section headers + if [[ $line =~ ^###[[:space:]]([0-9]+\.[[:space:]].+) ]]; then + # Print previous section stats if any + if [[ -n $current_section ]] && (( section_total > 0 )); then + section_percentage=$((section_completed * 100 / section_total)) + printf "%-40s %3d/%3d (%3d%%)\n" "$current_section" "$section_completed" "$section_total" "$section_percentage" + fi + + # Start new section + current_section="${BASH_REMATCH[1]}" + section_total=0 + section_completed=0 + fi + + # Count tasks in current section + if [[ $line =~ ^[[:space:]]*-[[:space:]]\[([[:space:]]|x)\] ]]; then + ((section_total++)) + if [[ ${BASH_REMATCH[1]} == "x" ]]; then + ((section_completed++)) + fi + fi +done < tasks.md + +# Print last section +if [[ -n $current_section ]] && (( section_total > 0 )); then + section_percentage=$((section_completed * 100 / section_total)) + printf "%-40s %3d/%3d (%3d%%)\n" "$current_section" "$section_completed" "$section_total" "$section_percentage" +fi + +echo +echo "Next uncompleted tasks:" +echo "----------------------" +grep -n "^\s*- \[ \]" tasks.md | head -5 | while IFS= read -r line; do + echo "$line" +done \ No newline at end of file diff --git a/claude-integration-dev.md b/claude-integration-dev.md new file mode 100644 index 00000000..80d34446 --- /dev/null +++ b/claude-integration-dev.md @@ -0,0 +1,74 @@ +# Claude Integration Development Notes + +## Quick Start + +1. Check task progress: `./check-tasks.sh` +2. View tasks: `nvim tasks.md` +3. Start implementing: Begin with Phase 1 tasks + +## Key Files + +- `tasks.md` - Comprehensive task tracking (gitignored) +- `check-tasks.sh` - Progress tracking script (gitignored) +- `lua/nvim-claude/` - Plugin code (to be created) +- `lua/colinzhao/lazy/claude.lua` - Plugin config (to be created) + +## Development Workflow + +1. Pick a task from `tasks.md` +2. Implement the feature +3. Test it thoroughly +4. Mark task as complete: Change `- [ ]` to `- [x]` +5. Run `./check-tasks.sh` to see progress + +## Testing Commands + +```bash +# Test plugin loading +nvim -c "echo 'Plugin loaded'" -c "qa" + +# Test specific commands (once implemented) +nvim -c "ClaudeChat" +nvim -c "ClaudeBg 'test task'" +``` + +## Git Worktree Commands (for reference) + +```bash +# List worktrees +git worktree list + +# Add new worktree +git worktree add .agent-work/2024-01-15-feature main + +# Remove worktree +git worktree remove .agent-work/2024-01-15-feature +``` + +## Tmux Commands (for reference) + +```bash +# Split pane horizontally +tmux split-window -h -p 40 + +# Name current pane +tmux select-pane -T "claude-chat" + +# List panes with names +tmux list-panes -F "#{pane_id} #{pane_title}" + +# Send keys to specific pane +tmux send-keys -t %1 "echo 'Hello'" Enter +``` + +## Architecture Notes + +- **Tmux-first**: All Claude interactions in tmux panes +- **Git worktrees**: For agent isolation +- **State management**: JSON files in `.agent-work/.state/` +- **No nvim terminal**: Respect existing workflow + +## Current Status + +- Phase: Planning → Implementation +- Next: Create plugin skeleton (Task 1.1) \ No newline at end of file diff --git a/lua/colinzhao/lazy/claude.lua b/lua/colinzhao/lazy/claude.lua new file mode 100644 index 00000000..c5eff464 --- /dev/null +++ b/lua/colinzhao/lazy/claude.lua @@ -0,0 +1,13 @@ +return { + dir = vim.fn.stdpath('config') .. '/lua/nvim-claude', + name = 'nvim-claude', + config = function() + require('nvim-claude').setup({ + -- Custom config can go here + }) + end, + dependencies = { + 'nvim-telescope/telescope.nvim', -- For agent picker + 'tpope/vim-fugitive', -- Already installed, for diffs + }, +} \ No newline at end of file diff --git a/lua/colinzhao/lazy/init.lua b/lua/colinzhao/lazy/init.lua index 92e777a9..ca72c4cb 100644 --- a/lua/colinzhao/lazy/init.lua +++ b/lua/colinzhao/lazy/init.lua @@ -81,4 +81,7 @@ return { { 'prisma/vim-prisma', }, + + -- Claude integration + require 'colinzhao.lazy.claude', } diff --git a/lua/nvim-claude/commands.lua b/lua/nvim-claude/commands.lua new file mode 100644 index 00000000..fbbc4fd0 --- /dev/null +++ b/lua/nvim-claude/commands.lua @@ -0,0 +1,723 @@ +-- 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 = '+', + 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' + }) +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 +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) + 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 + + -- Create worktree or clone + 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 + end + end + + -- Create mission log + local log_content = string.format( + "Agent Mission Log\n================\n\nTask: %s\nStarted: %s\nStatus: Active\n\n", + task, + os.date('%Y-%m-%d %H:%M:%S') + ) + 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 + -- Register agent + local agent_id = claude.registry.register(task, agent_dir, window_id, window_name) + + -- 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) + + -- 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.", + task, agent_dir + ) + 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', + agent_id, + task, + agent_dir, + window_name + ), vim.log.levels.INFO) + else + vim.notify('Failed to create agent window', vim.log.levels.ERROR) + end +end + +-- List all agents +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 + + -- Create a simple list view + local lines = { 'Claude Agents:', '' } + + -- 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) + + 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')) + 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 = { + 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 + +-- 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() + local removed = claude.registry.cleanup(claude.config.agents.cleanup_days) + + 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) + 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 + +return M \ No newline at end of file diff --git a/lua/nvim-claude/git.lua b/lua/nvim-claude/git.lua new file mode 100644 index 00000000..66d73555 --- /dev/null +++ b/lua/nvim-claude/git.lua @@ -0,0 +1,138 @@ +-- 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 'main' + + -- 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 + + -- Create worktree + local cmd = string.format('git worktree add "%s" "%s" 2>&1', path, branch) + local result, err = utils.exec(cmd) + + if err then + return false, result + end + + return true, { path = path, branch = branch } +end + +-- Remove a worktree +function M.remove_worktree(path) + local cmd = string.format('git worktree remove "%s" --force 2>&1', path) + local _, err = utils.exec(cmd) + return err == nil +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 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/init.lua b/lua/nvim-claude/init.lua new file mode 100644 index 00000000..6cc5766e --- /dev/null +++ b/lua/nvim-claude/init.lua @@ -0,0 +1,135 @@ +-- 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) + + -- 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') + + -- Initialize submodules with config + M.tmux.setup(M.config.tmux) + M.git.setup(M.config.agents) + M.registry.setup(M.config.agents) + + -- Set up commands + M.commands.setup(M) + + -- 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/mappings.lua b/lua/nvim-claude/mappings.lua new file mode 100644 index 00000000..0fcfc9e6 --- /dev/null +++ b/lua/nvim-claude/mappings.lua @@ -0,0 +1,67 @@ +-- 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' }, + } + }) + end +end + +return M \ No newline at end of file diff --git a/lua/nvim-claude/registry.lua b/lua/nvim-claude/registry.lua new file mode 100644 index 00000000..af8b8fc2 --- /dev/null +++ b/lua/nvim-claude/registry.lua @@ -0,0 +1,199 @@ +-- 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() + local content = utils.read_file(M.registry_path) + if content then + local ok, data = pcall(vim.json.decode, content) + if ok and type(data) == 'table' then + M.agents = data + M.validate_agents() + else + M.agents = {} + end + else + 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 + if utils.file_exists(agent.work_dir .. '/mission.log') then + -- Check if tmux window still exists + local window_exists = M.check_window_exists(agent.window_id) + + if window_exists then + agent.status = 'active' + 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) + 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(), + } + + 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() + 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 + 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.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 + + return string.format( + '[%s] %s (%s) - %s', + agent.status:upper(), + agent.task, + age_str, + agent.window_name or 'unknown' + ) +end + +return M \ No newline at end of file diff --git a/lua/nvim-claude/tmux.lua b/lua/nvim-claude/tmux.lua new file mode 100644 index 00000000..d225b7a8 --- /dev/null +++ b/lua/nvim-claude/tmux.lua @@ -0,0 +1,271 @@ +-- 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 new file mode 100644 index 00000000..ac5043de --- /dev/null +++ b/lua/nvim-claude/utils.lua @@ -0,0 +1,116 @@ +-- Utility functions for nvim-claude +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 and stat.type == 'file' +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 + +return M \ No newline at end of file diff --git a/tasks.md b/tasks.md new file mode 100644 index 00000000..b0e270c0 --- /dev/null +++ b/tasks.md @@ -0,0 +1,261 @@ +# Neovim Claude Integration - Task Tracking + +## Project Overview + +Building a Claude Code integration for Neovim that works seamlessly with a tmux-based workflow. The integration will support both interactive Claude sessions and autonomous "background agents" that can work on tasks independently. + +## Core Principles + +- **Tmux-first**: All Claude interactions happen in tmux panes/windows +- **Non-intrusive**: Doesn't change existing nvim/tmux workflow +- **Git-aware**: Leverages git worktrees for safe parallel development +- **Observable**: Easy to monitor what agents are doing + +## Feature Breakdown + +### 1. Core Plugin Infrastructure + +#### 1.1 Plugin Setup +- [x] Create plugin directory structure (`lua/nvim-claude/`) +- [x] Set up basic plugin module with `setup()` function +- [x] Add configuration options table +- [x] Create plugin entry in `lua/colinzhao/lazy/claude.lua` +- [x] Test: Plugin loads without errors + +#### 1.2 Configuration Management +- [ ] Define default configuration schema +- [ ] Implement config validation +- [ ] Support user config overrides +- [ ] Add config options for: + - [ ] Tmux pane split direction and size + - [ ] Agent work directory name + - [ ] Git worktree vs full clone preference + - [ ] Auto-gitignore behavior +- [ ] Test: Config changes apply correctly + +#### 1.3 Utility Functions +- [x] Create tmux interaction module +- [x] Add git operations wrapper (worktree, status, diff) +- [x] Implement filesystem utilities (create dirs, check paths) +- [ ] Add logging/debugging functions +- [ ] Test: Each utility function in isolation + +### 2. Basic Claude Chat Integration + +#### 2.1 Tmux Pane Management +- [x] Function to create new tmux pane +- [x] Function to find existing Claude panes +- [x] Pane naming/identification system +- [x] Handle pane closing/cleanup +- [ ] Test: Can spawn and track tmux panes + +#### 2.2 Claude Chat Command +- [x] Implement `:ClaudeChat` command +- [x] Open Claude in configured tmux split +- [x] Reuse existing pane if available +- [ ] Pass current file context if requested +- [ ] Test: Command opens Claude reliably + +#### 2.3 Context Sharing +- [x] `:ClaudeSendBuffer` - Send current buffer +- [x] `:ClaudeSendSelection` - Send visual selection +- [ ] `:ClaudeSendHunk` - Send git hunk under cursor +- [x] Add appropriate context headers +- [ ] Test: Each send command works correctly + +### 3. Background Agent System + +#### 3.1 Agent Work Directory Management +- [ ] Create `.agent-work/` in project root +- [ ] Auto-add to `.gitignore` if not present +- [ ] Generate timestamped subdirectories +- [ ] Clean up old agent directories (configurable) +- [ ] Test: Directory creation and gitignore updates + +#### 3.2 Git Worktree Integration +- [ ] Function to create worktree for agent +- [ ] Handle worktree naming (avoid conflicts) +- [ ] Support fallback to full clone if worktrees unavailable +- [ ] Track worktree <-> agent mapping +- [ ] Test: Worktree creation and tracking + +#### 3.3 Agent Spawning +- [x] Implement `:ClaudeBg ` command +- [x] Create agent work directory +- [x] Set up git worktree +- [x] Spawn tmux window/session for agent +- [ ] Initialize Claude with task context +- [x] Create mission log file +- [ ] Test: Full agent spawn workflow + +#### 3.4 Agent Tracking +- [ ] Maintain registry of active agents +- [ ] Store agent metadata (task, start time, status) +- [ ] Persist registry across nvim sessions +- [ ] Auto-detect terminated agents +- [ ] Test: Registry operations and persistence + +### 4. Diff Viewing and Review + +#### 4.1 Fugitive Integration +- [ ] Function to diff agent worktree against main +- [ ] `:ClaudeDiff [agent]` command +- [ ] Support for three-way diffs +- [ ] Quick diff preview in floating window +- [ ] Test: Diff viewing with fugitive + +#### 4.2 Alternative Diff Viewers +- [ ] Optional diffview.nvim integration +- [ ] Built-in diff visualization for quick preview +- [ ] Multi-file diff summary +- [ ] Test: Each diff viewer works correctly + +#### 4.3 Change Review Workflow +- [ ] List changed files from agent +- [ ] Preview individual file changes +- [ ] Cherry-pick specific changes +- [ ] Bulk apply agent changes +- [ ] Test: Full review workflow + +### 5. Telescope Integration + +#### 5.1 Agent Browser +- [ ] Custom Telescope picker for agents +- [ ] Show agent status, task, runtime +- [ ] Preview agent mission log +- [ ] Actions: open, diff, terminate +- [ ] Test: Telescope picker functionality + +#### 5.2 Agent Work Browser +- [ ] Browse files in agent worktrees +- [ ] Quick diff against main branch +- [ ] Open files from agent in main nvim +- [ ] Test: File browsing and operations + +### 6. Status and Monitoring + +#### 6.1 Status Line Integration +- [ ] Component showing active agent count +- [ ] Quick status of current agent +- [ ] Click to open agent picker +- [ ] Test: Status line updates correctly + +#### 6.2 Mission Logs +- [ ] Structured log format for agents +- [ ] Log Claude interactions +- [ ] Track file changes +- [ ] Command history +- [ ] Test: Logging functionality + +#### 6.3 Notifications +- [ ] Agent completion notifications +- [ ] Error/failure alerts +- [ ] Optional progress notifications +- [ ] Test: Notification system + +### 7. Safety and Convenience Features + +#### 7.1 Snapshot System +- [ ] Auto-snapshot before applying changes +- [ ] Named snapshots for important states +- [ ] Snapshot browser/restore +- [ ] Test: Snapshot and restore + +#### 7.2 Quick Commands +- [ ] `:ClaudeKill [agent]` - Terminate agent +- [ ] `:ClaudeClean` - Clean up old agents +- [ ] `:ClaudeSwitch [agent]` - Switch to agent tmux +- [ ] Test: Each command functions correctly + +#### 7.3 Keybindings +- [ ] Default keybinding set +- [ ] Which-key integration +- [ ] User-customizable bindings +- [ ] Test: Keybindings work as expected + +### 8. Advanced Features (Phase 2) + +#### 8.1 Agent Templates +- [ ] Predefined agent configurations +- [ ] Task-specific prompts +- [ ] Custom agent behaviors +- [ ] Test: Template system + +#### 8.2 Multi-Agent Coordination +- [ ] Agent communication system +- [ ] Shared context between agents +- [ ] Agent task dependencies +- [ ] Test: Multi-agent scenarios + +#### 8.3 Integration with Other Plugins +- [ ] Harpoon-style agent switching +- [ ] Trouble.nvim for agent issues +- [ ] Oil.nvim for agent file management +- [ ] Test: Each integration + +## Implementation Phases + +### Phase 1: MVP (Week 1-2) +1. Core infrastructure (1.1-1.3) +2. Basic Claude chat (2.1-2.3) +3. Simple agent spawning (3.1-3.3) +4. Basic diff viewing (4.1) + +### Phase 2: Core Features (Week 3-4) +1. Agent tracking (3.4) +2. Full diff/review workflow (4.2-4.3) +3. Telescope integration (5.1-5.2) +4. Basic monitoring (6.1-6.2) + +### Phase 3: Polish (Week 5-6) +1. Safety features (7.1-7.2) +2. Keybindings and UX (7.3) +3. Notifications (6.3) +4. Documentation and tests + +### Phase 4: Advanced (Future) +1. Agent templates (8.1) +2. Multi-agent features (8.2) +3. Deep plugin integrations (8.3) + +## Technical Decisions + +### Git Strategy +- **Primary**: Git worktrees for speed and efficiency +- **Fallback**: Full clones for compatibility +- **Safety**: Never modify main working directory + +### Tmux Integration +- Use `tmux send-keys` for Claude interaction +- Session/window naming for organization +- Preserve user's tmux workflow + +### State Management +- Agent registry in `~/.local/share/nvim/claude-agents/` +- Per-project state in `.agent-work/.state/` +- JSON for serialization + +### Error Handling +- Graceful degradation (worktree → clone) +- Clear error messages +- Recovery procedures for common issues + +## Success Metrics + +- [ ] Can spawn background agent in < 3 seconds +- [ ] Agent changes reviewable in < 5 keystrokes +- [ ] Zero interference with normal nvim workflow +- [ ] Works on macOS and Linux +- [ ] Clear documentation for all features + +## Next Steps + +1. Review and refine task list +2. Set up development environment +3. Create plugin skeleton +4. Begin Phase 1 implementation + +--- + +*Last Updated: [Current Date]* +*Status: Planning Phase* diff --git a/test-phase1.lua b/test-phase1.lua new file mode 100644 index 00000000..7f408f25 --- /dev/null +++ b/test-phase1.lua @@ -0,0 +1,228 @@ +-- Test script for Phase 1 features +-- Run with: nvim -l test-phase1.lua + +local test_results = {} +local function test(name, fn) + local ok, err = pcall(fn) + table.insert(test_results, { + name = name, + passed = ok, + error = err + }) + if ok then + print("✓ " .. name) + else + print("✗ " .. name .. ": " .. tostring(err)) + end +end + +-- Mock vim globals for testing +_G.vim = _G.vim or { + fn = { + stdpath = function(what) + if what == 'config' then + return os.getenv("HOME") .. "/.config/nvim" + elseif what == 'data' then + return os.getenv("HOME") .. "/.local/share/nvim" + end + end, + expand = function(str) + if str == '%:t' then return 'test.lua' end + return str + end, + getcwd = function() return os.getenv("PWD") or "/tmp" end, + mkdir = function() return true end, + }, + api = { + nvim_buf_get_lines = function() return {"line1", "line2"} end, + nvim_create_buf = function() return 1 end, + nvim_buf_set_lines = function() end, + nvim_buf_set_option = function() end, + nvim_open_win = function() return 1 end, + nvim_win_set_option = function() end, + nvim_buf_set_keymap = function() end, + }, + bo = { filetype = 'lua' }, + o = { columns = 80, lines = 24 }, + loop = { + fs_stat = function(path) + if path:match('%.lua$') then + return { type = 'file' } + else + return { type = 'directory' } + end + end + }, + notify = function(msg, level) + print("[NOTIFY] " .. tostring(msg)) + end, + log = { levels = { INFO = 1, WARN = 2, ERROR = 3 } }, + json = { + encode = function(t) return vim.inspect(t) end, + decode = function(s) return {} end, + }, + inspect = function(t) + if type(t) == 'table' then + return '{table}' + end + return tostring(t) + end, + tbl_deep_extend = function(behavior, ...) + local result = {} + for _, tbl in ipairs({...}) do + for k, v in pairs(tbl) do + result[k] = v + end + end + return result + end, + tbl_isempty = function(t) + return next(t) == nil + end, + wait = function() end, +} + +-- Add to package path +package.path = package.path .. ";./lua/?.lua;./lua/?/init.lua" + +-- Test 1: Plugin loads without errors +test("Plugin loads", function() + local claude = require('nvim-claude') + assert(claude ~= nil, "Plugin module should load") + assert(type(claude.setup) == 'function', "setup function should exist") +end) + +-- Test 2: Configuration validation +test("Config validation accepts valid config", function() + local claude = require('nvim-claude') + claude.setup({ + tmux = { + split_direction = 'v', + split_size = 30, + }, + agents = { + max_agents = 3, + cleanup_days = 5, + } + }) + assert(claude.config.tmux.split_direction == 'v') + assert(claude.config.tmux.split_size == 30) +end) + +test("Config validation rejects invalid config", function() + local claude = require('nvim-claude') + local captured_errors = {} + local old_notify = vim.notify + vim.notify = function(msg) + table.insert(captured_errors, msg) + end + + claude.setup({ + tmux = { + split_direction = 'x', -- Invalid + split_size = 150, -- Invalid + } + }) + + vim.notify = old_notify + assert(#captured_errors > 0, "Should have validation errors") +end) + +-- Test 3: Registry operations +test("Registry can register and retrieve agents", function() + local registry = require('nvim-claude.registry') + registry.setup({}) + + -- Mock file operations + registry.save = function() return true end + + local id = registry.register("Test task", "/tmp/agent-test", "window123", "claude-test") + assert(id ~= nil, "Should return agent ID") + + local agent = registry.get(id) + assert(agent ~= nil, "Should retrieve agent") + assert(agent.task == "Test task", "Should have correct task") + assert(agent.status == "active", "Should be active") +end) + +test("Registry tracks active count", function() + local registry = require('nvim-claude.registry') + registry.agents = {} -- Clear + + registry.register("Task 1", "/tmp/agent1", "win1", "claude1") + registry.register("Task 2", "/tmp/agent2", "win2", "claude2") + + assert(registry.get_active_count() == 2, "Should have 2 active agents") + + -- Mark one as completed + local agents = registry.agents + for id, _ in pairs(agents) do + registry.update_status(id, "completed") + break + end + + assert(registry.get_active_count() == 1, "Should have 1 active agent") +end) + +-- Test 4: Tmux text batching +test("Tmux send_text_to_pane creates temp file", function() + local tmux = require('nvim-claude.tmux') + local utils = require('nvim-claude.utils') + + -- Mock exec to capture commands + local executed_commands = {} + utils.exec = function(cmd) + table.insert(executed_commands, cmd) + return "", nil + end + + -- Mock os.tmpname + local tmpfile = "/tmp/test-nvim-claude.txt" + os.tmpname = function() return tmpfile end + + tmux.send_text_to_pane("pane123", "Multi\nline\ntext") + + -- Should have load-buffer and paste-buffer commands + local found_load = false + local found_paste = false + for _, cmd in ipairs(executed_commands) do + if cmd:match("load%-buffer") then found_load = true end + if cmd:match("paste%-buffer") then found_paste = true end + end + + assert(found_load, "Should use tmux load-buffer") + assert(found_paste, "Should use tmux paste-buffer") +end) + +-- Test 5: Utils functions +test("Utils timestamp format", function() + local utils = require('nvim-claude.utils') + local ts = utils.timestamp() + assert(ts:match("^%d%d%d%d%-%d%d%-%d%d%-%d%d%d%d%d%d$"), "Should match timestamp format") +end) + +test("Utils agent dirname sanitization", function() + local utils = require('nvim-claude.utils') + local dirname = utils.agent_dirname("Fix bug in foo/bar.lua!") + assert(not dirname:match("/"), "Should not contain slashes") + assert(not dirname:match("!"), "Should not contain special chars") + assert(dirname:match("^agent%-"), "Should start with agent-") +end) + +-- Print summary +print("\n=== Test Summary ===") +local passed = 0 +local failed = 0 +for _, result in ipairs(test_results) do + if result.passed then + passed = passed + 1 + else + failed = failed + 1 + end +end + +print(string.format("Passed: %d", passed)) +print(string.format("Failed: %d", failed)) +print(string.format("Total: %d", passed + failed)) + +os.exit(failed > 0 and 1 or 0) \ No newline at end of file diff --git a/test-plugin.sh b/test-plugin.sh new file mode 100755 index 00000000..4262cc77 --- /dev/null +++ b/test-plugin.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Test if nvim-claude plugin loads correctly + +echo "Testing nvim-claude plugin..." + +# Test 1: Check if nvim loads without errors +echo -n "Test 1 - Plugin loads: " +nvim -c "echo 'Testing plugin load'" -c "qa" 2>&1 +if [ $? -eq 0 ]; then + echo "✓ PASSED" +else + echo "✗ FAILED" + exit 1 +fi + +# Test 2: Check if commands are available +echo -n "Test 2 - Commands exist: " +OUTPUT=$(nvim -c "echo exists(':ClaudeChat')" -c "qa!" 2>&1 | tail -1) +if [[ "$OUTPUT" == *"2"* ]]; then + echo "✓ PASSED" +else + echo "✗ FAILED - ClaudeChat command not found" + exit 1 +fi + +# Test 3: Check tmux availability +echo -n "Test 3 - Tmux available: " +if command -v tmux &> /dev/null; then + echo "✓ PASSED" +else + echo "✗ WARNING - tmux not found" +fi + +# Test 4: Check if we're in tmux +echo -n "Test 4 - Inside tmux: " +if [ -n "$TMUX" ]; then + echo "✓ PASSED" +else + echo "✗ WARNING - Not inside tmux session" +fi + +echo +echo "Basic tests completed!" \ No newline at end of file