phase 1 mvp
parent
abaca67308
commit
2a71451ca3
@ -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
|
@ -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)
|
@ -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
|
||||
},
|
||||
}
|
@ -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<CR>', { silent = true })
|
||||
vim.api.nvim_buf_set_keymap(buf, 'n', '<Esc>', ':close<CR>', { 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', '<Esc>', '', {
|
||||
callback = close_window,
|
||||
silent = true,
|
||||
noremap = true,
|
||||
})
|
||||
|
||||
-- Space to toggle selection
|
||||
vim.api.nvim_buf_set_keymap(buf, 'n', '<Space>', '', {
|
||||
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<CR>', { silent = true })
|
||||
vim.api.nvim_buf_set_keymap(buf, 'n', '<Esc>', ':close<CR>', { silent = true })
|
||||
end
|
||||
|
||||
return M
|
@ -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
|
@ -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 = '<leader>c',
|
||||
quick_prefix = '<C-c>',
|
||||
},
|
||||
}
|
||||
|
||||
-- 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
|
@ -0,0 +1,67 @@
|
||||
-- Keybinding mappings for nvim-claude
|
||||
local M = {}
|
||||
|
||||
function M.setup(config, commands)
|
||||
local prefix = config.prefix or '<leader>c'
|
||||
|
||||
-- Basic commands
|
||||
vim.keymap.set('n', prefix .. 'c', ':ClaudeChat<CR>', {
|
||||
desc = 'Open Claude chat',
|
||||
silent = true
|
||||
})
|
||||
|
||||
vim.keymap.set('n', prefix .. 's', ':ClaudeSendBuffer<CR>', {
|
||||
desc = 'Send buffer to Claude',
|
||||
silent = true
|
||||
})
|
||||
|
||||
vim.keymap.set('v', prefix .. 'v', ':ClaudeSendSelection<CR>', {
|
||||
desc = 'Send selection to Claude',
|
||||
silent = true
|
||||
})
|
||||
|
||||
vim.keymap.set('n', prefix .. 'h', ':ClaudeSendHunk<CR>', {
|
||||
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<CR>', {
|
||||
desc = 'List agents',
|
||||
silent = true
|
||||
})
|
||||
|
||||
vim.keymap.set('n', prefix .. 'k', ':ClaudeKill<CR>', {
|
||||
desc = 'Kill agent',
|
||||
silent = true
|
||||
})
|
||||
|
||||
vim.keymap.set('n', prefix .. 'x', ':ClaudeClean<CR>', {
|
||||
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
|
@ -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
|
@ -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
|
@ -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 <percent>% syntax (>= 3.4)
|
||||
function M.tmux_supports_length_percent()
|
||||
return M.tmux_version() >= 3.4
|
||||
end
|
||||
|
||||
return M
|
@ -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 <task>` 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*
|
@ -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)
|
@ -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!"
|
Loading…
Reference in New Issue