phase 1 mvp

This commit is contained in:
zolinthecow 2025-07-09 13:33:29 -07:00
parent abaca67308
commit 2a71451ca3
15 changed files with 2334 additions and 0 deletions

View file

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

View file

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

View file

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

138
lua/nvim-claude/git.lua Normal file
View file

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

135
lua/nvim-claude/init.lua Normal file
View file

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

View file

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

View file

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

271
lua/nvim-claude/tmux.lua Normal file
View file

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

116
lua/nvim-claude/utils.lua Normal file
View file

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