phase 1 mvp

pull/1635/head
zolinthecow 3 days ago
parent abaca67308
commit 2a71451ca3

5
.gitignore vendored

@ -3,7 +3,12 @@ test.sh
.luarc.json
nvim
hihi
spell/
lazy-lock.json
**/.DS_Store
# Development tracking
.agent-work

@ -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
},
}

@ -81,4 +81,7 @@ return {
{
'prisma/vim-prisma',
},
-- Claude integration
require 'colinzhao.lazy.claude',
}

@ -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…
Cancel
Save