mirror of
https://github.com/nvim-lua/kickstart.nvim.git
synced 2025-08-07 03:25:12 +02:00
phase 1 mvp
This commit is contained in:
parent
abaca67308
commit
2a71451ca3
15 changed files with 2334 additions and 0 deletions
13
lua/colinzhao/lazy/claude.lua
Normal file
13
lua/colinzhao/lazy/claude.lua
Normal 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
|
||||
},
|
||||
}
|
|
@ -81,4 +81,7 @@ return {
|
|||
{
|
||||
'prisma/vim-prisma',
|
||||
},
|
||||
|
||||
-- Claude integration
|
||||
require 'colinzhao.lazy.claude',
|
||||
}
|
||||
|
|
723
lua/nvim-claude/commands.lua
Normal file
723
lua/nvim-claude/commands.lua
Normal 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
138
lua/nvim-claude/git.lua
Normal 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
135
lua/nvim-claude/init.lua
Normal 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
|
67
lua/nvim-claude/mappings.lua
Normal file
67
lua/nvim-claude/mappings.lua
Normal 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
|
199
lua/nvim-claude/registry.lua
Normal file
199
lua/nvim-claude/registry.lua
Normal 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
271
lua/nvim-claude/tmux.lua
Normal 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
116
lua/nvim-claude/utils.lua
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue