remove nvim-claude to convert to submodule

pull/1635/head
zolinthecow 21 hours ago
parent 6f18060a51
commit e26997b7fa

File diff suppressed because it is too large Load Diff

@ -1,853 +0,0 @@
-- Diff review system for nvim-claude using diffview.nvim
local M = {}
-- State tracking - make it persistent across hook calls
M.current_review = M.current_review or nil
function M.setup()
-- Set up keybindings
M.setup_keybindings()
vim.notify('Diff review system loaded (using diffview.nvim)', vim.log.levels.DEBUG)
end
-- Handle Claude edit completion
function M.handle_claude_edit(stash_ref, pre_edit_ref)
if not stash_ref then
vim.notify('No stash reference provided for diff review', vim.log.levels.ERROR)
return
end
vim.notify('Processing Claude edit with stash: ' .. stash_ref, vim.log.levels.INFO)
-- Get list of changed files
local changed_files = M.get_changed_files(stash_ref)
if not changed_files or #changed_files == 0 then
vim.notify('No changes detected from Claude edit', vim.log.levels.INFO)
return
end
-- Initialize review session
M.current_review = {
stash_ref = stash_ref,
pre_edit_ref = pre_edit_ref, -- Store the pre-edit commit reference
timestamp = os.time(),
changed_files = changed_files,
}
-- Notify user about changes
vim.notify(string.format('Claude made changes to %d file(s): %s', #changed_files, table.concat(changed_files, ', ')), vim.log.levels.INFO)
vim.notify('Use <leader>dd to open diffview, <leader>df for fugitive, <leader>dc to clear review', vim.log.levels.INFO)
-- Automatically open diffview
M.open_diffview()
end
-- Handle Claude stashes (only show Claude changes)
function M.handle_claude_stashes(baseline_ref)
if not baseline_ref then
vim.notify('No baseline reference provided for Claude stashes', vim.log.levels.ERROR)
return
end
vim.notify('Showing Claude stashes against baseline: ' .. baseline_ref, vim.log.levels.INFO)
-- Get Claude stashes
local claude_stashes = M.get_claude_stashes()
if not claude_stashes or #claude_stashes == 0 then
vim.notify('No Claude stashes found', vim.log.levels.INFO)
return
end
-- Initialize review session for Claude stashes
M.current_review = {
baseline_ref = baseline_ref,
timestamp = os.time(),
claude_stashes = claude_stashes,
current_stash_index = 0, -- Show cumulative view by default
is_stash_based = true,
}
-- Notify user about changes
vim.notify(string.format('Found %d Claude stash(es). Use <leader>dd for cumulative view, <leader>dh to browse.', #claude_stashes), vim.log.levels.INFO)
-- Automatically open cumulative stash view
M.open_cumulative_stash_view()
end
-- Handle cumulative diff (always show against baseline) - legacy support
function M.handle_cumulative_diff(baseline_ref)
-- Redirect to new stash-based handler
M.handle_claude_stashes(baseline_ref)
end
-- Get list of files changed in the stash
function M.get_changed_files(stash_ref)
local utils = require 'nvim-claude.utils'
local cmd = string.format('git stash show %s --name-only', stash_ref)
local result = utils.exec(cmd)
if not result or result == '' then
return {}
end
local files = {}
for line in result:gmatch '[^\n]+' do
if line ~= '' then
table.insert(files, line)
end
end
return files
end
-- Get list of files changed since baseline
function M.get_changed_files_since_baseline(baseline_ref)
local utils = require 'nvim-claude.utils'
local cmd = string.format('git diff --name-only %s', baseline_ref)
local result = utils.exec(cmd)
if not result or result == '' then
return {}
end
local files = {}
for line in result:gmatch '[^\n]+' do
if line ~= '' then
table.insert(files, line)
end
end
return files
end
-- Get Claude stashes (only stashes with [claude-edit] messages)
function M.get_claude_stashes()
local utils = require 'nvim-claude.utils'
local cmd = 'git stash list'
local result = utils.exec(cmd)
if not result or result == '' then
return {}
end
local stashes = {}
for line in result:gmatch '[^\n]+' do
if line ~= '' and line:match '%[claude%-edit%]' then
local stash_ref = line:match '^(stash@{%d+})'
if stash_ref then
table.insert(stashes, {
ref = stash_ref,
message = line:match ': (.+)$' or line,
})
end
end
end
return stashes
end
-- Set up keybindings for diff review
function M.setup_keybindings()
-- Review actions
vim.keymap.set('n', '<leader>dd', M.open_diffview, { desc = 'Open Claude diff in diffview' })
vim.keymap.set('n', '<leader>df', M.open_fugitive, { desc = 'Open Claude diff in fugitive' })
vim.keymap.set('n', '<leader>dc', M.clear_review, { desc = 'Clear Claude review session' })
vim.keymap.set('n', '<leader>dl', M.list_changes, { desc = 'List Claude changed files' })
vim.keymap.set('n', '<leader>da', M.accept_changes, { desc = 'Accept all Claude changes' })
vim.keymap.set('n', '<leader>dr', M.decline_changes, { desc = 'Decline all Claude changes' })
-- Stash browsing
vim.keymap.set('n', '<leader>dh', M.browse_claude_stashes, { desc = 'Browse Claude stash history' })
vim.keymap.set('n', '<leader>dp', M.previous_stash, { desc = 'View previous Claude stash' })
vim.keymap.set('n', '<leader>dn', M.next_stash, { desc = 'View next Claude stash' })
-- Unified view
vim.keymap.set('n', '<leader>du', M.open_unified_view, { desc = 'Open Claude diff in unified view' })
-- Hunk operations
vim.keymap.set('n', '<leader>dka', M.accept_hunk_at_cursor, { desc = 'Accept Claude hunk at cursor' })
vim.keymap.set('n', '<leader>dkr', M.reject_hunk_at_cursor, { desc = 'Reject Claude hunk at cursor' })
end
-- Open diffview for current review
function M.open_diffview()
if not M.current_review then
-- Try to recover stash-based session from baseline
local utils = require 'nvim-claude.utils'
local baseline_ref = utils.read_file '/tmp/claude-baseline-commit'
-- If no baseline file, but we have Claude stashes, use HEAD as baseline
local claude_stashes = M.get_claude_stashes()
if claude_stashes and #claude_stashes > 0 then
if not baseline_ref or baseline_ref == '' then
baseline_ref = 'HEAD'
vim.notify('No baseline found, using HEAD as baseline', vim.log.levels.INFO)
else
baseline_ref = baseline_ref:gsub('%s+', '')
end
M.current_review = {
baseline_ref = baseline_ref,
timestamp = os.time(),
claude_stashes = claude_stashes,
current_stash_index = 0, -- Show cumulative view by default
is_stash_based = true,
}
vim.notify(string.format('Recovered Claude stash session with %d stashes', #claude_stashes), vim.log.levels.INFO)
end
if not M.current_review then
vim.notify('No active review session', vim.log.levels.INFO)
return
end
end
-- Use stash-based diff if available
if M.current_review.is_stash_based then
M.open_cumulative_stash_view()
return
end
-- Legacy: Use cumulative diff if available
if M.current_review.is_cumulative then
M.open_cumulative_diffview()
return
end
-- Check if diffview is available
local ok, diffview = pcall(require, 'diffview')
if not ok then
vim.notify('diffview.nvim not available, falling back to fugitive', vim.log.levels.WARN)
M.open_fugitive()
return
end
-- Use the pre-edit reference if available
if M.current_review.pre_edit_ref then
local cmd = 'DiffviewOpen ' .. M.current_review.pre_edit_ref
vim.notify('Opening diffview with pre-edit commit: ' .. cmd, vim.log.levels.INFO)
vim.cmd(cmd)
else
-- Fallback to comparing stash with its parent
vim.notify('No pre-edit commit found, falling back to stash comparison', vim.log.levels.WARN)
local cmd = string.format('DiffviewOpen %s^..%s', M.current_review.stash_ref, M.current_review.stash_ref)
vim.notify('Opening diffview: ' .. cmd, vim.log.levels.INFO)
vim.cmd(cmd)
end
end
-- Open cumulative stash view (shows all Claude changes since baseline)
function M.open_cumulative_stash_view()
if not M.current_review then
vim.notify('No active review session', vim.log.levels.INFO)
return
end
-- Check if diffview is available
local ok, diffview = pcall(require, 'diffview')
if not ok then
vim.notify('diffview.nvim not available', vim.log.levels.WARN)
return
end
if M.current_review.is_stash_based and M.current_review.claude_stashes then
-- Show cumulative diff of all Claude stashes against baseline
local cmd = 'DiffviewOpen ' .. M.current_review.baseline_ref
vim.notify('Opening cumulative Claude stash view: ' .. cmd, vim.log.levels.INFO)
vim.cmd(cmd)
else
-- Fallback to old behavior
local cmd = 'DiffviewOpen ' .. M.current_review.baseline_ref
vim.notify('Opening cumulative diffview: ' .. cmd, vim.log.levels.INFO)
vim.cmd(cmd)
end
end
-- Open cumulative diffview (always against baseline) - legacy support
function M.open_cumulative_diffview()
M.open_cumulative_stash_view()
end
-- Open fugitive diff (fallback)
function M.open_fugitive()
if not M.current_review then
vim.notify('No active review session', vim.log.levels.INFO)
return
end
-- Use fugitive to show diff
local cmd = 'Gdiffsplit ' .. M.current_review.stash_ref
vim.notify('Opening fugitive: ' .. cmd, vim.log.levels.INFO)
vim.cmd(cmd)
end
-- List changed files
function M.list_changes()
if not M.current_review then
vim.notify('No active review session', vim.log.levels.INFO)
return
end
local files = M.current_review.changed_files
if #files == 0 then
vim.notify('No changes found', vim.log.levels.INFO)
return
end
-- Create a telescope picker if available, otherwise just notify
local ok, telescope = pcall(require, 'telescope.pickers')
if ok then
M.telescope_changed_files()
else
vim.notify('Changed files:', vim.log.levels.INFO)
for i, file in ipairs(files) do
vim.notify(string.format(' %d. %s', i, file), vim.log.levels.INFO)
end
end
end
-- Telescope picker for changed files
function M.telescope_changed_files()
local pickers = require 'telescope.pickers'
local finders = require 'telescope.finders'
local conf = require('telescope.config').values
pickers
.new({}, {
prompt_title = 'Claude Changed Files',
finder = finders.new_table {
results = M.current_review.changed_files,
},
sorter = conf.generic_sorter {},
attach_mappings = function(_, map)
map('i', '<CR>', function(prompt_bufnr)
local selection = require('telescope.actions.state').get_selected_entry()
require('telescope.actions').close(prompt_bufnr)
vim.cmd('edit ' .. selection[1])
M.open_diffview()
end)
return true
end,
})
:find()
end
-- Clear review session
function M.clear_review()
if M.current_review then
M.current_review = nil
-- Close diffview if it's open
pcall(function()
vim.cmd 'DiffviewClose'
end)
vim.notify('Claude review session cleared', vim.log.levels.INFO)
else
vim.notify('No active Claude review session', vim.log.levels.INFO)
end
end
-- Accept all Claude changes (update baseline)
function M.accept_changes()
local utils = require 'nvim-claude.utils'
-- Get project root
local git_root = utils.get_project_root()
if not git_root then
vim.notify('Not in a git repository', vim.log.levels.ERROR)
return
end
-- Create new baseline commit with current state
local timestamp = os.time()
local commit_msg = string.format('claude-baseline-%d', timestamp)
-- Stage all changes
local add_cmd = string.format('cd "%s" && git add -A', git_root)
local add_result, add_err = utils.exec(add_cmd)
if add_err then
vim.notify('Failed to stage changes: ' .. add_err, vim.log.levels.ERROR)
return
end
-- Create new baseline commit
local commit_cmd = string.format('cd "%s" && git commit -m "%s" --allow-empty', git_root, commit_msg)
local commit_result, commit_err = utils.exec(commit_cmd)
if commit_err and not commit_err:match 'nothing to commit' then
vim.notify('Failed to create new baseline: ' .. commit_err, vim.log.levels.ERROR)
return
end
-- Update baseline reference
local baseline_file = '/tmp/claude-baseline-commit'
utils.write_file(baseline_file, 'HEAD')
-- Clear review session
M.current_review = nil
-- Close diffview
pcall(function()
vim.cmd 'DiffviewClose'
end)
vim.notify('All Claude changes accepted! New baseline created.', vim.log.levels.INFO)
end
-- Decline all Claude changes (reset to baseline)
function M.decline_changes()
local utils = require 'nvim-claude.utils'
-- Get baseline commit
local baseline_file = '/tmp/claude-baseline-commit'
local baseline_ref = utils.read_file(baseline_file)
if not baseline_ref or baseline_ref == '' then
vim.notify('No baseline commit found', vim.log.levels.ERROR)
return
end
baseline_ref = baseline_ref:gsub('%s+', '')
-- Get project root
local git_root = utils.get_project_root()
if not git_root then
vim.notify('Not in a git repository', vim.log.levels.ERROR)
return
end
-- Reset to baseline (hard reset)
local reset_cmd = string.format('cd "%s" && git reset --hard %s', git_root, baseline_ref)
local reset_result, reset_err = utils.exec(reset_cmd)
if reset_err then
vim.notify('Failed to reset to baseline: ' .. reset_err, vim.log.levels.ERROR)
return
end
-- Clear review session
M.current_review = nil
-- Close diffview
pcall(function()
vim.cmd 'DiffviewClose'
end)
-- Refresh buffers
vim.cmd 'checktime'
vim.notify('All Claude changes declined! Reset to baseline.', vim.log.levels.INFO)
end
-- Browse Claude stashes (show list)
function M.browse_claude_stashes()
if not M.current_review or not M.current_review.is_stash_based then
vim.notify('No Claude stash session active', vim.log.levels.INFO)
return
end
local stashes = M.current_review.claude_stashes
if not stashes or #stashes == 0 then
vim.notify('No Claude stashes found', vim.log.levels.INFO)
return
end
-- Create a telescope picker if available, otherwise just notify
local ok, telescope = pcall(require, 'telescope.pickers')
if ok then
M.telescope_claude_stashes()
else
vim.notify('Claude stashes:', vim.log.levels.INFO)
for i, stash in ipairs(stashes) do
local marker = (i == M.current_review.current_stash_index) and ' [current]' or ''
vim.notify(string.format(' %d. %s%s', i, stash.message, marker), vim.log.levels.INFO)
end
end
end
-- View previous Claude stash
function M.previous_stash()
if not M.current_review or not M.current_review.is_stash_based then
vim.notify('No Claude stash session active', vim.log.levels.INFO)
return
end
local stashes = M.current_review.claude_stashes
if not stashes or #stashes == 0 then
vim.notify('No Claude stashes found', vim.log.levels.INFO)
return
end
local current_index = M.current_review.current_stash_index or 0
if current_index <= 1 then
vim.notify('Already at first stash', vim.log.levels.INFO)
return
end
M.current_review.current_stash_index = current_index - 1
M.view_specific_stash(M.current_review.current_stash_index)
end
-- View next Claude stash
function M.next_stash()
if not M.current_review or not M.current_review.is_stash_based then
vim.notify('No Claude stash session active', vim.log.levels.INFO)
return
end
local stashes = M.current_review.claude_stashes
if not stashes or #stashes == 0 then
vim.notify('No Claude stashes found', vim.log.levels.INFO)
return
end
local current_index = M.current_review.current_stash_index or 0
if current_index >= #stashes then
vim.notify('Already at last stash', vim.log.levels.INFO)
return
end
M.current_review.current_stash_index = current_index + 1
M.view_specific_stash(M.current_review.current_stash_index)
end
-- View a specific stash by index
function M.view_specific_stash(index)
if not M.current_review or not M.current_review.is_stash_based then
vim.notify('No Claude stash session active', vim.log.levels.INFO)
return
end
local stashes = M.current_review.claude_stashes
if not stashes or index < 1 or index > #stashes then
vim.notify('Invalid stash index', vim.log.levels.ERROR)
return
end
local stash = stashes[index]
-- Check if diffview is available
local ok, diffview = pcall(require, 'diffview')
if not ok then
vim.notify('diffview.nvim not available', vim.log.levels.WARN)
return
end
-- Open diffview for this specific stash
local cmd = string.format('DiffviewOpen %s^..%s', stash.ref, stash.ref)
vim.notify(string.format('Opening stash %d: %s', index, stash.message), vim.log.levels.INFO)
vim.cmd(cmd)
end
-- Telescope picker for Claude stashes
function M.telescope_claude_stashes()
local pickers = require 'telescope.pickers'
local finders = require 'telescope.finders'
local conf = require('telescope.config').values
local stashes = M.current_review.claude_stashes
local stash_entries = {}
for i, stash in ipairs(stashes) do
table.insert(stash_entries, {
value = i,
display = string.format('%d. %s', i, stash.message),
ordinal = stash.message,
})
end
pickers
.new({}, {
prompt_title = 'Claude Stash History',
finder = finders.new_table {
results = stash_entries,
entry_maker = function(entry)
return {
value = entry.value,
display = entry.display,
ordinal = entry.ordinal,
}
end,
},
sorter = conf.generic_sorter {},
attach_mappings = function(_, map)
map('i', '<CR>', function(prompt_bufnr)
local selection = require('telescope.actions.state').get_selected_entry()
require('telescope.actions').close(prompt_bufnr)
M.current_review.current_stash_index = selection.value
M.view_specific_stash(selection.value)
end)
return true
end,
})
:find()
end
-- Generate combined patch from all Claude stashes
function M.generate_claude_patch()
if not M.current_review or not M.current_review.is_stash_based then
vim.notify('No Claude stash session active', vim.log.levels.ERROR)
return nil
end
local utils = require 'nvim-claude.utils'
local baseline_ref = M.current_review.baseline_ref
-- Generate diff from baseline to current working directory
local cmd = string.format('git diff %s', baseline_ref)
local patch, err = utils.exec(cmd)
if err then
vim.notify('Failed to generate patch: ' .. err, vim.log.levels.ERROR)
return nil
end
return patch
end
-- Open unified view for Claude changes
function M.open_unified_view()
if not M.current_review then
-- Try to recover stash-based session from baseline
local utils = require 'nvim-claude.utils'
local baseline_ref = utils.read_file '/tmp/claude-baseline-commit'
-- If no baseline file, but we have Claude stashes, use HEAD as baseline
local claude_stashes = M.get_claude_stashes()
if claude_stashes and #claude_stashes > 0 then
if not baseline_ref or baseline_ref == '' then
baseline_ref = 'HEAD'
vim.notify('No baseline found, using HEAD as baseline', vim.log.levels.INFO)
else
baseline_ref = baseline_ref:gsub('%s+', '')
end
M.current_review = {
baseline_ref = baseline_ref,
timestamp = os.time(),
claude_stashes = claude_stashes,
current_stash_index = 0,
is_stash_based = true,
}
vim.notify(string.format('Recovered Claude stash session with %d stashes', #claude_stashes), vim.log.levels.INFO)
end
if not M.current_review then
vim.notify('No active review session', vim.log.levels.INFO)
return
end
end
-- Check if unified.nvim is available and load it
local ok, unified = pcall(require, 'unified')
if not ok then
vim.notify('unified.nvim not available, falling back to diffview', vim.log.levels.WARN)
M.open_diffview()
return
end
-- Ensure unified.nvim is set up
pcall(unified.setup, {})
-- Use unified.nvim to show diff against baseline
local baseline_ref = M.current_review.baseline_ref
-- Try the command with pcall to catch errors
local cmd_ok, cmd_err = pcall(function()
vim.cmd('Unified ' .. baseline_ref)
end)
if not cmd_ok then
vim.notify('Unified command failed: ' .. tostring(cmd_err) .. ', falling back to diffview', vim.log.levels.WARN)
M.open_diffview()
return
end
vim.notify('Claude unified diff opened. Use ]h/[h to navigate hunks', vim.log.levels.INFO)
end
-- Accept hunk at cursor position
function M.accept_hunk_at_cursor()
-- Get current buffer and check if we're in a diff view
local bufname = vim.api.nvim_buf_get_name(0)
local filetype = vim.bo.filetype
-- Check for various diff view types
local is_diff_view = bufname:match 'diffview://' or bufname:match 'Claude Unified Diff' or filetype == 'diff' or filetype == 'git'
if not is_diff_view then
vim.notify('This command only works in diff views', vim.log.levels.WARN)
return
end
-- Get current file and line from cursor position
local cursor_line = vim.api.nvim_win_get_cursor(0)[1]
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
-- Parse diff to find current hunk
local hunk_info = M.find_hunk_at_line(lines, cursor_line)
if not hunk_info then
vim.notify('No hunk found at cursor position', vim.log.levels.WARN)
return
end
-- Apply the hunk
M.apply_hunk(hunk_info)
end
-- Reject hunk at cursor position
function M.reject_hunk_at_cursor()
-- Get current buffer and check if we're in a diff view
local bufname = vim.api.nvim_buf_get_name(0)
local filetype = vim.bo.filetype
-- Check for various diff view types
local is_diff_view = bufname:match 'diffview://' or bufname:match 'Claude Unified Diff' or filetype == 'diff' or filetype == 'git'
if not is_diff_view then
vim.notify('This command only works in diff views', vim.log.levels.WARN)
return
end
-- Get current file and line from cursor position
local cursor_line = vim.api.nvim_win_get_cursor(0)[1]
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
-- Parse diff to find current hunk
local hunk_info = M.find_hunk_at_line(lines, cursor_line)
if not hunk_info then
vim.notify('No hunk found at cursor position', vim.log.levels.WARN)
return
end
vim.notify(
string.format('Rejected hunk in %s at lines %d-%d', hunk_info.file, hunk_info.old_start, hunk_info.old_start + hunk_info.old_count - 1),
vim.log.levels.INFO
)
end
-- Find hunk information at given line in diff buffer
function M.find_hunk_at_line(lines, target_line)
local current_file = nil
local in_hunk = false
local hunk_start_line = nil
local hunk_lines = {}
for i, line in ipairs(lines) do
-- File header
if line:match '^diff %-%-git' or line:match '^diff %-%-cc' then
current_file = line:match 'b/(.+)$'
elseif line:match '^%+%+%+ b/(.+)' then
current_file = line:match '^%+%+%+ b/(.+)'
end
-- Hunk header
if line:match '^@@' then
-- If we were in a hunk that included target line, return it
if in_hunk and hunk_start_line and target_line >= hunk_start_line and target_line < i then
return M.parse_hunk_info(hunk_lines, current_file, hunk_start_line)
end
-- Start new hunk
in_hunk = true
hunk_start_line = i
hunk_lines = { line }
elseif in_hunk then
-- Collect hunk lines
if line:match '^[%+%-%s]' then
table.insert(hunk_lines, line)
else
-- End of hunk
if hunk_start_line and target_line >= hunk_start_line and target_line < i then
return M.parse_hunk_info(hunk_lines, current_file, hunk_start_line)
end
in_hunk = false
end
end
end
-- Check last hunk
if in_hunk and hunk_start_line and target_line >= hunk_start_line then
return M.parse_hunk_info(hunk_lines, current_file, hunk_start_line)
end
return nil
end
-- Parse hunk information from diff lines
function M.parse_hunk_info(hunk_lines, file, start_line)
if #hunk_lines == 0 then
return nil
end
local header = hunk_lines[1]
local old_start, old_count, new_start, new_count = header:match '^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@'
if not old_start then
return nil
end
return {
file = file,
old_start = tonumber(old_start),
old_count = tonumber(old_count) or 1,
new_start = tonumber(new_start),
new_count = tonumber(new_count) or 1,
lines = hunk_lines,
buffer_start_line = start_line,
}
end
-- Apply a specific hunk to the working directory
function M.apply_hunk(hunk_info)
local utils = require 'nvim-claude.utils'
-- Create a patch with just this hunk
local patch_lines = {
'diff --git a/' .. hunk_info.file .. ' b/' .. hunk_info.file,
'index 0000000..0000000 100644',
'--- a/' .. hunk_info.file,
'+++ b/' .. hunk_info.file,
}
-- Add hunk lines
for _, line in ipairs(hunk_info.lines) do
table.insert(patch_lines, line)
end
local patch_content = table.concat(patch_lines, '\n')
-- Write patch to temp file
local temp_patch = '/tmp/claude-hunk-patch.diff'
utils.write_file(temp_patch, patch_content)
-- Apply the patch
local git_root = utils.get_project_root()
if not git_root then
vim.notify('Not in a git repository', vim.log.levels.ERROR)
return
end
local cmd = string.format('cd "%s" && git apply --cached "%s"', git_root, temp_patch)
local result, err = utils.exec(cmd)
if err then
-- Try without --cached
cmd = string.format('cd "%s" && git apply "%s"', git_root, temp_patch)
result, err = utils.exec(cmd)
if err then
vim.notify('Failed to apply hunk: ' .. err, vim.log.levels.ERROR)
return
end
end
vim.notify(string.format('Applied hunk to %s', hunk_info.file), vim.log.levels.INFO)
-- Refresh the buffer if it's open
vim.cmd 'checktime'
end
return M

@ -1,186 +0,0 @@
-- 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 M.default_branch()
-- 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
-- Generate unique branch name for the worktree
local worktree_branch = 'agent-' .. utils.timestamp()
-- Create worktree with new branch based on specified branch
local cmd = string.format('git worktree add -b "%s" "%s" "%s" 2>&1', worktree_branch, path, branch)
local result, err = utils.exec(cmd)
if err then
return false, result
end
-- For background agents, ensure no hooks by creating empty .claude directory
-- This prevents inline diffs from triggering
local claude_dir = path .. '/.claude'
if vim.fn.isdirectory(claude_dir) == 0 then
vim.fn.mkdir(claude_dir, 'p')
end
-- Create empty settings.json to disable hooks
local empty_settings = '{"hooks": {}}'
utils.write_file(claude_dir .. '/settings.json', empty_settings)
return true, { path = path, branch = worktree_branch, base_branch = branch }
end
-- Remove a worktree
function M.remove_worktree(path)
-- First try to remove as git worktree
local cmd = string.format('git worktree remove "%s" --force 2>&1', path)
local result, err = utils.exec(cmd)
-- If it's not a worktree or already removed, just delete the directory
if err or result:match('not a working tree') then
local rm_cmd = string.format('rm -rf "%s"', path)
utils.exec(rm_cmd)
end
return true
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 default branch (usually main or master)
function M.default_branch()
-- Try to get the default branch from remote
local result = utils.exec('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null')
if result and result ~= '' then
local branch = result:match('refs/remotes/origin/(.+)')
if branch then
return branch:gsub('\n', '')
end
end
-- Fallback: check if main or master exists
local main_exists = utils.exec('git show-ref --verify --quiet refs/heads/main')
if main_exists and main_exists == '' then
return 'main'
end
local master_exists = utils.exec('git show-ref --verify --quiet refs/heads/master')
if master_exists and master_exists == '' then
return 'master'
end
-- Final fallback
return 'main'
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

@ -1,726 +0,0 @@
-- Claude Code hooks integration for nvim-claude
local M = {}
-- Track hook state
M.pre_edit_commit = nil
M.stable_baseline_ref = nil -- The stable baseline to compare all changes against
M.claude_edited_files = {} -- Track which files Claude has edited
-- Update stable baseline after accepting changes
function M.update_stable_baseline()
local utils = require('nvim-claude.utils')
local persistence = require('nvim-claude.inline-diff-persistence')
-- Create a new stash with current state as the new baseline
local message = 'nvim-claude-baseline-accepted-' .. os.time()
-- Create a stash object without removing changes from working directory
local stash_cmd = 'git stash create'
local stash_hash, err = utils.exec(stash_cmd)
if not err and stash_hash and stash_hash ~= '' then
-- Store the stash with a message
stash_hash = stash_hash:gsub('%s+', '') -- trim whitespace
local store_cmd = string.format('git stash store -m "%s" %s', message, stash_hash)
utils.exec(store_cmd)
-- Update our stable baseline reference
M.stable_baseline_ref = stash_hash
persistence.current_stash_ref = stash_hash
-- Save the updated state
persistence.save_state({ stash_ref = stash_hash })
end
end
function M.setup()
-- Setup persistence layer on startup
vim.defer_fn(function()
M.setup_persistence()
end, 500)
-- Set up autocmd for opening files
M.setup_file_open_autocmd()
end
-- Pre-tool-use hook: Create baseline stash if we don't have one
function M.pre_tool_use_hook()
local persistence = require 'nvim-claude.inline-diff-persistence'
-- Only create a baseline if we don't have one yet
if not M.stable_baseline_ref then
-- Create baseline stash synchronously
local stash_ref = persistence.create_stash('nvim-claude: baseline ' .. os.date('%Y-%m-%d %H:%M:%S'))
if stash_ref then
M.stable_baseline_ref = stash_ref
persistence.current_stash_ref = stash_ref
end
end
-- Return success to allow the tool to proceed
return true
end
-- Post-tool-use hook: Create stash of Claude's changes and trigger diff review
function M.post_tool_use_hook()
-- Run directly without vim.schedule for testing
local utils = require 'nvim-claude.utils'
local persistence = require 'nvim-claude.inline-diff-persistence'
-- Refresh all buffers to show Claude's changes
vim.cmd 'checktime'
-- Check if Claude made any changes
local git_root = utils.get_project_root()
if not git_root then
return
end
local status_cmd = string.format('cd "%s" && git status --porcelain', git_root)
local status_result = utils.exec(status_cmd)
if not status_result or status_result == '' then
return
end
-- Get list of modified files
local modified_files = {}
local inline_diff = require 'nvim-claude.inline-diff'
for line in status_result:gmatch '[^\n]+' do
local file = line:match '^.M (.+)$' or line:match '^M. (.+)$' or line:match '^.. (.+)$'
if file then
table.insert(modified_files, file)
-- Track that Claude edited this file
M.claude_edited_files[file] = true
-- Track this file in the diff files list immediately
local full_path = git_root .. '/' .. file
inline_diff.diff_files[full_path] = -1 -- Use -1 to indicate no buffer yet
end
end
-- Always use the stable baseline reference for comparison
local stash_ref = M.stable_baseline_ref or persistence.current_stash_ref
-- If no baseline exists at all, create one now (shouldn't happen normally)
if not stash_ref then
stash_ref = persistence.create_stash('nvim-claude: baseline ' .. os.date('%Y-%m-%d %H:%M:%S'))
M.stable_baseline_ref = stash_ref
persistence.current_stash_ref = stash_ref
end
if stash_ref then
-- Process inline diffs for currently open buffers
local opened_inline = false
for _, file in ipairs(modified_files) do
local full_path = git_root .. '/' .. file
-- Find buffer with this file
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then
local buf_name = vim.api.nvim_buf_get_name(buf)
if buf_name == full_path or buf_name:match('/' .. file:gsub('([^%w])', '%%%1') .. '$') then
-- Show inline diff for this open buffer
M.show_inline_diff_for_file(buf, file, git_root, stash_ref)
opened_inline = true
-- Switch to that buffer if it's not the current one
if buf ~= vim.api.nvim_get_current_buf() then
vim.api.nvim_set_current_buf(buf)
end
break -- Only show inline diff for first matching buffer
end
end
end
if opened_inline then
break
end
end
-- If no inline diff was shown, just notify the user
if not opened_inline then
vim.notify('Claude made changes. Open the modified files to see inline diffs.', vim.log.levels.INFO)
end
end
end
-- Helper function to show inline diff for a file
function M.show_inline_diff_for_file(buf, file, git_root, stash_ref)
local utils = require 'nvim-claude.utils'
local inline_diff = require 'nvim-claude.inline-diff'
-- Only show inline diff if Claude edited this file
if not M.claude_edited_files[file] then
return false
end
-- Get baseline from git stash
local stash_cmd = string.format('cd "%s" && git show %s:%s 2>/dev/null', git_root, stash_ref, file)
local original_content = utils.exec(stash_cmd)
if original_content then
-- Get current content
local current_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
local current_content = table.concat(current_lines, '\n')
-- Show inline diff
inline_diff.show_inline_diff(buf, original_content, current_content)
return true
end
return false
end
-- Test inline diff manually
function M.test_inline_diff()
vim.notify('Testing inline diff manually...', vim.log.levels.INFO)
local utils = require 'nvim-claude.utils'
local persistence = require 'nvim-claude.inline-diff-persistence'
local git_root = utils.get_project_root()
if not git_root then
vim.notify('Not in git repository', vim.log.levels.ERROR)
return
end
-- Get current buffer
local bufnr = vim.api.nvim_get_current_buf()
local buf_name = vim.api.nvim_buf_get_name(bufnr)
if buf_name == '' then
vim.notify('Current buffer has no file', vim.log.levels.ERROR)
return
end
-- Get relative path
local relative_path = buf_name:gsub(git_root .. '/', '')
vim.notify('Testing inline diff for: ' .. relative_path, vim.log.levels.INFO)
-- Get baseline content - check for updated baseline first
local inline_diff = require 'nvim-claude.inline-diff'
local original_content = nil
-- Check if we have an updated baseline in memory
vim.notify('DEBUG: Checking for baseline in buffer ' .. bufnr, vim.log.levels.INFO)
vim.notify('DEBUG: Available baselines: ' .. vim.inspect(vim.tbl_keys(inline_diff.original_content)), vim.log.levels.INFO)
if inline_diff.original_content[bufnr] then
original_content = inline_diff.original_content[bufnr]
vim.notify('Using updated baseline from memory (length: ' .. #original_content .. ')', vim.log.levels.INFO)
elseif persistence.current_stash_ref then
-- Try to get from stash
local stash_cmd = string.format('cd "%s" && git show %s:%s 2>/dev/null', git_root, persistence.current_stash_ref, relative_path)
local git_err
original_content, git_err = utils.exec(stash_cmd)
if git_err then
vim.notify('Failed to get stash content: ' .. git_err, vim.log.levels.ERROR)
return
end
vim.notify('Using stash baseline: ' .. persistence.current_stash_ref, vim.log.levels.INFO)
else
-- Fall back to HEAD
local baseline_cmd = string.format('cd "%s" && git show HEAD:%s 2>/dev/null', git_root, relative_path)
local git_err
original_content, git_err = utils.exec(baseline_cmd)
if git_err then
vim.notify('Failed to get baseline content: ' .. git_err, vim.log.levels.ERROR)
return
end
vim.notify('Using HEAD as baseline', vim.log.levels.INFO)
end
-- Get current content
local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local current_content = table.concat(current_lines, '\n')
-- Show inline diff
inline_diff.show_inline_diff(bufnr, original_content, current_content)
end
-- Set up autocmd to check for diffs when opening files
function M.setup_file_open_autocmd()
vim.api.nvim_create_autocmd({"BufRead", "BufNewFile"}, {
pattern = "*",
callback = function(args)
local bufnr = args.buf
local file_path = vim.api.nvim_buf_get_name(bufnr)
if file_path == '' then return end
local utils = require 'nvim-claude.utils'
local git_root = utils.get_project_root()
if not git_root then return end
-- Get relative path
local relative_path = file_path:gsub(git_root .. '/', '')
-- Check if this file was edited by Claude
if M.claude_edited_files[relative_path] and M.stable_baseline_ref then
-- Show inline diff for this file
vim.defer_fn(function()
M.show_inline_diff_for_file(bufnr, relative_path, git_root, M.stable_baseline_ref)
end, 50) -- Small delay to ensure buffer is fully loaded
end
end,
group = vim.api.nvim_create_augroup('NvimClaudeFileOpen', { clear = true })
})
end
-- Setup persistence and restore saved state on Neovim startup
function M.setup_persistence()
local persistence = require 'nvim-claude.inline-diff-persistence'
-- Setup persistence autocmds
persistence.setup_autocmds()
-- Try to restore any saved diffs
local restored = persistence.restore_diffs()
-- Also restore the baseline reference from persistence if it exists
if persistence.current_stash_ref then
M.stable_baseline_ref = persistence.current_stash_ref
vim.notify('Restored baseline: ' .. M.stable_baseline_ref, vim.log.levels.DEBUG)
end
-- Don't create a startup baseline - only create baselines when Claude makes edits
end
-- Manual hook testing
function M.test_hooks()
vim.notify('=== Testing nvim-claude hooks ===', vim.log.levels.INFO)
local persistence = require 'nvim-claude.inline-diff-persistence'
-- Test creating a stash
vim.notify('1. Creating test stash...', vim.log.levels.INFO)
local stash_ref = persistence.create_stash('nvim-claude: test stash')
if stash_ref then
persistence.current_stash_ref = stash_ref
vim.notify('Stash created: ' .. stash_ref, vim.log.levels.INFO)
else
vim.notify('Failed to create stash', vim.log.levels.ERROR)
end
-- Simulate making a change
vim.notify('2. Make some changes to test files now...', vim.log.levels.INFO)
-- Test post-tool-use hook after a delay
vim.notify('3. Will trigger post-tool-use hook in 3 seconds...', vim.log.levels.INFO)
vim.defer_fn(function()
M.post_tool_use_hook()
end, 3000)
vim.notify('=== Hook testing started - make changes now! ===', vim.log.levels.INFO)
end
-- Install Claude Code hooks
function M.install_hooks()
local utils = require 'nvim-claude.utils'
-- Get project root
local project_root = utils.get_project_root()
if not project_root then
vim.notify('Not in a git repository', vim.log.levels.ERROR)
return
end
-- Create .claude directory
local claude_dir = project_root .. '/.claude'
if not vim.fn.isdirectory(claude_dir) then
vim.fn.mkdir(claude_dir, 'p')
end
-- Create hooks configuration
local server_name = vim.v.servername or 'NVIM'
local pre_command = string.format(
'nvim --headless --server %s --remote-send "<C-\\><C-N>:lua require(\'nvim-claude.hooks\').pre_tool_use_hook()<CR>" 2>/dev/null || true',
server_name
)
local post_command = string.format(
'nvim --headless --server %s --remote-send "<C-\\><C-N>:lua require(\'nvim-claude.hooks\').post_tool_use_hook()<CR>" 2>/dev/null || true',
server_name
)
local hooks_config = {
hooks = {
PreToolUse = {
{
matcher = 'Edit|Write|MultiEdit', -- Only match file editing tools
hooks = {
{
type = 'command',
command = pre_command,
},
},
},
},
PostToolUse = {
{
matcher = 'Edit|Write|MultiEdit', -- Only match file editing tools
hooks = {
{
type = 'command',
command = post_command,
},
},
},
},
},
}
-- Write hooks configuration
local settings_file = claude_dir .. '/settings.json'
local success, err = utils.write_json(settings_file, hooks_config)
if success then
-- Add .claude to gitignore if needed
local gitignore_path = project_root .. '/.gitignore'
local gitignore_content = utils.read_file(gitignore_path) or ''
if not gitignore_content:match '%.claude/' then
local new_content = gitignore_content .. '\n# Claude Code hooks\n.claude/\n'
utils.write_file(gitignore_path, new_content)
vim.notify('Added .claude/ to .gitignore', vim.log.levels.INFO)
end
vim.notify('Claude Code hooks installed successfully', vim.log.levels.INFO)
vim.notify('Hooks configuration written to: ' .. settings_file, vim.log.levels.INFO)
else
vim.notify('Failed to install hooks: ' .. (err or 'unknown error'), vim.log.levels.ERROR)
end
end
-- Uninstall Claude Code hooks
function M.uninstall_hooks()
local utils = require 'nvim-claude.utils'
-- Get project root
local project_root = utils.get_project_root()
if not project_root then
vim.notify('Not in a git repository', vim.log.levels.ERROR)
return
end
local settings_file = project_root .. '/.claude/settings.json'
if vim.fn.filereadable(settings_file) then
vim.fn.delete(settings_file)
vim.notify('Claude Code hooks uninstalled', vim.log.levels.INFO)
else
vim.notify('No hooks configuration found', vim.log.levels.INFO)
end
end
-- Commands for manual hook management
function M.setup_commands()
vim.api.nvim_create_user_command('ClaudeTestHooks', function()
M.test_hooks()
end, {
desc = 'Test Claude Code hooks',
})
vim.api.nvim_create_user_command('ClaudeTestInlineDiff', function()
M.test_inline_diff()
end, {
desc = 'Test Claude inline diff manually',
})
vim.api.nvim_create_user_command('ClaudeTestKeymap', function()
require('nvim-claude.inline-diff').test_keymap()
end, {
desc = 'Test Claude keymap functionality',
})
vim.api.nvim_create_user_command('ClaudeDebugInlineDiff', function()
require('nvim-claude.inline-diff-debug').debug_inline_diff()
end, {
desc = 'Debug Claude inline diff state',
})
vim.api.nvim_create_user_command('ClaudeUpdateBaseline', function()
local bufnr = vim.api.nvim_get_current_buf()
local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local current_content = table.concat(current_lines, '\n')
local inline_diff = require 'nvim-claude.inline-diff'
inline_diff.original_content[bufnr] = current_content
-- Save updated state
local persistence = require 'nvim-claude.inline-diff-persistence'
if persistence.current_stash_ref then
persistence.save_state({ stash_ref = persistence.current_stash_ref })
end
vim.notify('Baseline updated to current buffer state', vim.log.levels.INFO)
end, {
desc = 'Update Claude baseline to current buffer state',
})
vim.api.nvim_create_user_command('ClaudeTestDiff', function()
local utils = require 'nvim-claude.utils'
-- Check if we're in a git repository
local git_root = utils.get_project_root()
if not git_root then
return
end
-- Check if there are any changes
local status_cmd = string.format('cd "%s" && git status --porcelain', git_root)
local status_result = utils.exec(status_cmd)
if not status_result or status_result == '' then
vim.notify('No changes to test', vim.log.levels.INFO)
return
end
-- Create test stash without restoring (to avoid conflicts)
local timestamp = os.date '%Y-%m-%d %H:%M:%S'
local stash_msg = string.format('[claude-test] %s', timestamp)
local stash_cmd = string.format('cd "%s" && git stash push -u -m "%s"', git_root, stash_msg)
local stash_result, stash_err = utils.exec(stash_cmd)
if stash_err then
vim.notify('Failed to create test stash: ' .. stash_err, vim.log.levels.ERROR)
return
end
-- Trigger diff review with the stash (no pre-edit ref for manual test)
local diff_review = require 'nvim-claude.diff-review'
diff_review.handle_claude_edit('stash@{0}', nil)
-- Pop the stash to restore changes
vim.defer_fn(function()
local pop_cmd = string.format('cd "%s" && git stash pop --quiet', git_root)
utils.exec(pop_cmd)
vim.cmd 'checktime' -- Refresh buffers
end, 100)
end, {
desc = 'Test Claude diff review with current changes',
})
vim.api.nvim_create_user_command('ClaudeInstallHooks', function()
M.install_hooks()
end, {
desc = 'Install Claude Code hooks for this project',
})
vim.api.nvim_create_user_command('ClaudeUninstallHooks', function()
M.uninstall_hooks()
end, {
desc = 'Uninstall Claude Code hooks for this project',
})
vim.api.nvim_create_user_command('ClaudeResetBaseline', function()
-- Clear all baselines and force new baseline on next edit
local inline_diff = require 'nvim-claude.inline-diff'
local persistence = require 'nvim-claude.inline-diff-persistence'
-- Clear in-memory baselines
inline_diff.original_content = {}
-- Clear stable baseline reference
M.stable_baseline_ref = nil
persistence.current_stash_ref = nil
M.claude_edited_files = {}
-- Clear persistence state
persistence.clear_state()
vim.notify('Baseline reset. Next edit will create a new baseline.', vim.log.levels.INFO)
end, {
desc = 'Reset Claude baseline for cumulative diffs',
})
vim.api.nvim_create_user_command('ClaudeAcceptAll', function()
local inline_diff = require 'nvim-claude.inline-diff'
inline_diff.accept_all_files()
end, {
desc = 'Accept all Claude diffs across all files',
})
vim.api.nvim_create_user_command('ClaudeTrackModified', function()
-- Manually track all modified files as Claude-edited
local utils = require 'nvim-claude.utils'
local git_root = utils.get_project_root()
if not git_root then
vim.notify('Not in a git repository', vim.log.levels.ERROR)
return
end
local status_cmd = string.format('cd "%s" && git status --porcelain', git_root)
local status_result = utils.exec(status_cmd)
if not status_result or status_result == '' then
vim.notify('No modified files found', vim.log.levels.INFO)
return
end
local count = 0
for line in status_result:gmatch '[^\n]+' do
local file = line:match '^.M (.+)$' or line:match '^M. (.+)$'
if file then
M.claude_edited_files[file] = true
count = count + 1
end
end
vim.notify(string.format('Tracked %d modified files as Claude-edited', count), vim.log.levels.INFO)
-- Also ensure we have a baseline
if not M.stable_baseline_ref then
local persistence = require 'nvim-claude.inline-diff-persistence'
local stash_list = utils.exec('git stash list | grep "nvim-claude: baseline" | head -1')
if stash_list and stash_list ~= '' then
local stash_ref = stash_list:match('^(stash@{%d+})')
if stash_ref then
M.stable_baseline_ref = stash_ref
persistence.current_stash_ref = stash_ref
vim.notify('Using baseline: ' .. stash_ref, vim.log.levels.INFO)
end
end
end
end, {
desc = 'Track all modified files as Claude-edited (for debugging)',
})
vim.api.nvim_create_user_command('ClaudeDebugTracking', function()
-- Debug command to show current tracking state
local inline_diff = require 'nvim-claude.inline-diff'
local persistence = require 'nvim-claude.inline-diff-persistence'
local utils = require 'nvim-claude.utils'
vim.notify('=== Claude Tracking Debug ===', vim.log.levels.INFO)
vim.notify('Stable baseline: ' .. (M.stable_baseline_ref or 'none'), vim.log.levels.INFO)
vim.notify('Persistence stash ref: ' .. (persistence.current_stash_ref or 'none'), vim.log.levels.INFO)
vim.notify('Claude edited files: ' .. vim.inspect(M.claude_edited_files), vim.log.levels.INFO)
vim.notify('Diff files: ' .. vim.inspect(vim.tbl_keys(inline_diff.diff_files)), vim.log.levels.INFO)
vim.notify('Active diffs: ' .. vim.inspect(vim.tbl_keys(inline_diff.active_diffs)), vim.log.levels.INFO)
-- Check current file
local current_file = vim.api.nvim_buf_get_name(0)
local git_root = utils.get_project_root()
if git_root then
local relative_path = current_file:gsub('^' .. vim.pesc(git_root) .. '/', '')
vim.notify('Current file relative path: ' .. relative_path, vim.log.levels.INFO)
vim.notify('Is tracked: ' .. tostring(M.claude_edited_files[relative_path] ~= nil), vim.log.levels.INFO)
end
end, {
desc = 'Debug Claude tracking state',
})
vim.api.nvim_create_user_command('ClaudeRestoreState', function()
-- Manually restore the state
local persistence = require 'nvim-claude.inline-diff-persistence'
local restored = persistence.restore_diffs()
if persistence.current_stash_ref then
M.stable_baseline_ref = persistence.current_stash_ref
end
vim.notify('Manually restored state', vim.log.levels.INFO)
end, {
desc = 'Manually restore Claude diff state',
})
vim.api.nvim_create_user_command('ClaudeCleanStaleTracking', function()
local utils = require 'nvim-claude.utils'
local persistence = require 'nvim-claude.inline-diff-persistence'
local git_root = utils.get_project_root()
if not git_root or not M.stable_baseline_ref then
vim.notify('No git root or baseline found', vim.log.levels.ERROR)
return
end
local cleaned_count = 0
local files_to_remove = {}
-- Check each tracked file for actual differences
for file_path, _ in pairs(M.claude_edited_files) do
local diff_cmd = string.format('cd "%s" && git diff %s -- "%s" 2>/dev/null', git_root, M.stable_baseline_ref, file_path)
local diff_output = utils.exec(diff_cmd)
if not diff_output or diff_output == '' then
-- No differences, remove from tracking
table.insert(files_to_remove, file_path)
cleaned_count = cleaned_count + 1
end
end
-- Remove files with no differences
for _, file_path in ipairs(files_to_remove) do
M.claude_edited_files[file_path] = nil
end
-- Save updated state if we have a persistence stash ref
if persistence.current_stash_ref then
persistence.save_state({ stash_ref = persistence.current_stash_ref })
end
vim.notify(string.format('Cleaned %d stale tracked files', cleaned_count), vim.log.levels.INFO)
end, {
desc = 'Clean up stale Claude file tracking',
})
vim.api.nvim_create_user_command('ClaudeUntrackFile', function()
-- Remove current file from Claude tracking
local utils = require 'nvim-claude.utils'
local git_root = utils.get_project_root()
if not git_root then
vim.notify('Not in a git repository', vim.log.levels.ERROR)
return
end
local file_path = vim.api.nvim_buf_get_name(0)
local relative_path = file_path:gsub(git_root .. '/', '')
if M.claude_edited_files[relative_path] then
M.claude_edited_files[relative_path] = nil
vim.notify('Removed ' .. relative_path .. ' from Claude tracking', vim.log.levels.INFO)
-- Also close any active inline diff for this buffer
local inline_diff = require 'nvim-claude.inline-diff'
local bufnr = vim.api.nvim_get_current_buf()
if inline_diff.has_active_diff(bufnr) then
inline_diff.close_inline_diff(bufnr)
end
else
vim.notify(relative_path .. ' is not in Claude tracking', vim.log.levels.INFO)
end
end, {
desc = 'Remove current file from Claude edited files tracking',
})
end
-- Cleanup old temp files (no longer cleans up commits)
function M.cleanup_old_files()
-- Clean up old temp files
local temp_files = {
'/tmp/claude-pre-edit-commit',
'/tmp/claude-baseline-commit',
'/tmp/claude-last-snapshot',
'/tmp/claude-hook-test.log',
}
for _, file in ipairs(temp_files) do
if vim.fn.filereadable(file) == 1 then
vim.fn.delete(file)
end
end
end
return M

@ -1,153 +0,0 @@
-- 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)
-- Force reload modules to ensure latest code
package.loaded['nvim-claude.hooks'] = nil
package.loaded['nvim-claude.diff-review'] = nil
-- 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')
M.hooks = require('nvim-claude.hooks')
M.diff_review = require('nvim-claude.diff-review')
M.settings_updater = require('nvim-claude.settings-updater')
-- Initialize submodules with config
M.tmux.setup(M.config.tmux)
M.git.setup(M.config.agents)
M.registry.setup(M.config.agents)
M.hooks.setup()
M.diff_review.setup()
M.settings_updater.setup()
-- Set up commands
M.commands.setup(M)
M.hooks.setup_commands()
-- Auto-install hooks if we're in a git repository
vim.defer_fn(function()
if M.utils.get_project_root() then
M.hooks.install_hooks()
end
end, 100)
-- 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

@ -1,57 +0,0 @@
local M = {}
-- Debug function to check inline diff state
function M.debug_inline_diff()
local inline_diff = require('nvim-claude.inline-diff')
local bufnr = vim.api.nvim_get_current_buf()
vim.notify('=== Inline Diff Debug Info ===', vim.log.levels.INFO)
-- Check if inline diff is active for current buffer
local diff_data = inline_diff.active_diffs[bufnr]
if diff_data then
vim.notify(string.format('✓ Inline diff ACTIVE for buffer %d', bufnr), vim.log.levels.INFO)
vim.notify(string.format(' - Hunks: %d', #diff_data.hunks), vim.log.levels.INFO)
vim.notify(string.format(' - Current hunk: %d', diff_data.current_hunk or 0), vim.log.levels.INFO)
vim.notify(string.format(' - Original content length: %d', #(inline_diff.original_content[bufnr] or '')), vim.log.levels.INFO)
vim.notify(string.format(' - New content length: %d', #(diff_data.new_content or '')), vim.log.levels.INFO)
else
vim.notify(string.format('✗ No inline diff for buffer %d', bufnr), vim.log.levels.WARN)
end
-- Check all active diffs
local count = 0
for buf, _ in pairs(inline_diff.active_diffs) do
count = count + 1
end
vim.notify(string.format('Total active inline diffs: %d', count), vim.log.levels.INFO)
-- Check keymaps
local keymaps = vim.api.nvim_buf_get_keymap(bufnr, 'n')
local found_ir = false
local leader = vim.g.mapleader or '\\'
local ir_pattern = leader .. 'ir'
vim.notify(string.format('Looking for keymap: %s', ir_pattern), vim.log.levels.INFO)
for _, map in ipairs(keymaps) do
if map.lhs == ir_pattern or map.lhs == '<leader>ir' then
found_ir = true
vim.notify(string.format('✓ Found keymap: %s -> %s', map.lhs, map.desc or 'no desc'), vim.log.levels.INFO)
break
end
end
if not found_ir then
vim.notify('✗ <leader>ir keymap not found', vim.log.levels.WARN)
-- List all keymaps that start with leader
vim.notify('Buffer keymaps starting with leader:', vim.log.levels.INFO)
for _, map in ipairs(keymaps) do
if map.lhs:match('^' .. vim.pesc(leader)) or map.lhs:match('^<leader>') then
vim.notify(string.format(' %s -> %s', map.lhs, map.desc or 'no desc'), vim.log.levels.INFO)
end
end
end
end
return M

@ -1,314 +0,0 @@
-- Persistence layer for inline diffs
-- Manages saving/loading diff state across neovim sessions without polluting git history
local M = {}
local utils = require('nvim-claude.utils')
-- State file location
M.state_file = vim.fn.stdpath('data') .. '/nvim-claude-inline-diff-state.json'
-- Save current diff state
function M.save_state(diff_data)
-- Structure:
-- {
-- version: 1,
-- timestamp: <unix_timestamp>,
-- stash_ref: "stash@{0}",
-- files: {
-- "/path/to/file": {
-- original_content: "...",
-- hunks: [...],
-- applied_hunks: {...}
-- }
-- }
-- }
local hooks = require('nvim-claude.hooks')
local inline_diff = require('nvim-claude.inline-diff')
local state = {
version = 1,
timestamp = os.time(),
stash_ref = diff_data.stash_ref,
claude_edited_files = hooks.claude_edited_files or {},
diff_files = {}, -- Add diff_files to persistence
files = {}
}
-- Save all diff files (both opened and unopened)
for file_path, bufnr in pairs(inline_diff.diff_files) do
state.diff_files[file_path] = bufnr
end
-- Collect state from all buffers with active diffs
for file_path, bufnr in pairs(inline_diff.diff_files) do
if inline_diff.active_diffs[bufnr] then
local diff = inline_diff.active_diffs[bufnr]
state.files[file_path] = {
original_content = inline_diff.original_content[bufnr],
hunks = diff.hunks,
applied_hunks = diff.applied_hunks or {},
new_content = diff.new_content
}
end
end
-- Save to file
local success, err = utils.write_json(M.state_file, state)
if not success then
vim.notify('Failed to save inline diff state: ' .. err, vim.log.levels.ERROR)
return false
end
return true
end
-- Load saved diff state
function M.load_state()
if not utils.file_exists(M.state_file) then
return nil
end
local state, err = utils.read_json(M.state_file)
if not state then
vim.notify('Failed to load inline diff state: ' .. err, vim.log.levels.ERROR)
return nil
end
-- Validate version
if state.version ~= 1 then
vim.notify('Incompatible inline diff state version', vim.log.levels.WARN)
return nil
end
-- Check if stash still exists
if state.stash_ref then
local cmd = string.format('git stash list | grep -q "%s"', state.stash_ref:gsub("{", "\\{"):gsub("}", "\\}"))
local result = os.execute(cmd)
if result ~= 0 then
vim.notify('Saved stash no longer exists: ' .. state.stash_ref, vim.log.levels.WARN)
M.clear_state()
return nil
end
end
return state
end
-- Clear saved state
function M.clear_state()
if utils.file_exists(M.state_file) then
os.remove(M.state_file)
end
end
-- Restore diffs from saved state
function M.restore_diffs()
local state = M.load_state()
if not state then
return false
end
local inline_diff = require('nvim-claude.inline-diff')
local restored_count = 0
-- Restore diffs for each file
for file_path, file_state in pairs(state.files) do
-- Check if file exists and hasn't changed since the diff was created
if utils.file_exists(file_path) then
-- Read current content
local current_content = utils.read_file(file_path)
-- Check if the file matches what we expect (either original or with applied changes)
-- This handles the case where some hunks were accepted
if current_content then
-- Find or create buffer for this file
local bufnr = vim.fn.bufnr(file_path)
if bufnr == -1 then
-- File not loaded, we'll restore when it's opened
-- Store in a pending restores table
M.pending_restores = M.pending_restores or {}
M.pending_restores[file_path] = file_state
else
-- Restore the diff visualization
inline_diff.original_content[bufnr] = file_state.original_content
inline_diff.diff_files[file_path] = bufnr
inline_diff.active_diffs[bufnr] = {
hunks = file_state.hunks,
new_content = file_state.new_content,
current_hunk = 1,
applied_hunks = file_state.applied_hunks or {}
}
-- Apply visualization
inline_diff.apply_diff_visualization(bufnr)
inline_diff.setup_inline_keymaps(bufnr)
restored_count = restored_count + 1
end
end
end
end
if restored_count > 0 then
-- Silent restore - no notification
end
-- Store the stash reference for future operations
M.current_stash_ref = state.stash_ref
if M.current_stash_ref then
vim.notify('Restored stash reference: ' .. M.current_stash_ref, vim.log.levels.DEBUG)
end
-- Restore Claude edited files tracking
if state.claude_edited_files then
local hooks = require('nvim-claude.hooks')
hooks.claude_edited_files = state.claude_edited_files
vim.notify(string.format('Restored %d Claude edited files', vim.tbl_count(state.claude_edited_files)), vim.log.levels.DEBUG)
end
-- Restore diff_files for unopened files
if state.diff_files then
for file_path, bufnr in pairs(state.diff_files) do
-- Only restore if not already restored as an active diff
if not inline_diff.diff_files[file_path] then
-- Use -1 to indicate unopened file
inline_diff.diff_files[file_path] = bufnr == -1 and -1 or -1
end
end
vim.notify(string.format('Restored %d diff files', vim.tbl_count(state.diff_files)), vim.log.levels.DEBUG)
end
-- Also populate diff_files from claude_edited_files if needed
-- This ensures <leader>ci works even if diff_files wasn't properly saved
if state.claude_edited_files then
local utils = require('nvim-claude.utils')
local git_root = utils.get_project_root()
if git_root then
for relative_path, _ in pairs(state.claude_edited_files) do
local full_path = git_root .. '/' .. relative_path
-- Only add if not already in diff_files
if not inline_diff.diff_files[full_path] then
inline_diff.diff_files[full_path] = -1 -- Mark as unopened
vim.notify('Added ' .. relative_path .. ' to diff_files from claude_edited_files', vim.log.levels.DEBUG)
end
end
end
end
return true
end
-- Check for pending restores when a buffer is loaded
function M.check_pending_restore(bufnr)
if not M.pending_restores then
return
end
local file_path = vim.api.nvim_buf_get_name(bufnr)
local file_state = M.pending_restores[file_path]
if file_state then
local inline_diff = require('nvim-claude.inline-diff')
-- Restore the diff for this buffer
inline_diff.original_content[bufnr] = file_state.original_content
inline_diff.diff_files[file_path] = bufnr
inline_diff.active_diffs[bufnr] = {
hunks = file_state.hunks,
new_content = file_state.new_content,
current_hunk = 1,
applied_hunks = file_state.applied_hunks or {}
}
-- Apply visualization
inline_diff.apply_diff_visualization(bufnr)
inline_diff.setup_inline_keymaps(bufnr)
-- Remove from pending
M.pending_restores[file_path] = nil
-- Silent restore - no notification
end
end
-- Create a stash of current changes (instead of baseline commit)
function M.create_stash(message)
local utils = require('nvim-claude.utils')
message = message or 'nvim-claude: pre-edit state'
-- Create a stash object without removing changes from working directory
local stash_cmd = 'git stash create'
local stash_hash, err = utils.exec(stash_cmd)
if not err and stash_hash and stash_hash ~= '' then
-- Store the stash with a message
stash_hash = stash_hash:gsub('%s+', '') -- trim whitespace
local store_cmd = string.format('git stash store -m "%s" %s', message, stash_hash)
utils.exec(store_cmd)
-- Get the stash reference
local stash_list = utils.exec('git stash list -n 1')
if stash_list then
local stash_ref = stash_list:match('^(stash@{%d+})')
return stash_ref
end
end
return nil
end
-- Setup autocmds for persistence
function M.setup_autocmds()
local group = vim.api.nvim_create_augroup('NvimClaudeInlineDiffPersistence', { clear = true })
-- Save state before exiting vim
vim.api.nvim_create_autocmd('VimLeavePre', {
group = group,
callback = function()
local inline_diff = require('nvim-claude.inline-diff')
-- Only save if there are active diffs
local has_active_diffs = false
for _, diff in pairs(inline_diff.active_diffs) do
if diff then
has_active_diffs = true
break
end
end
if has_active_diffs and M.current_stash_ref then
M.save_state({ stash_ref = M.current_stash_ref })
else
-- Save just the Claude edited files tracking even if no active diffs
local hooks = require('nvim-claude.hooks')
if hooks.claude_edited_files and next(hooks.claude_edited_files) then
M.save_state({ stash_ref = M.current_stash_ref or '' })
end
end
end
})
-- Check for pending restores when buffers are loaded
vim.api.nvim_create_autocmd('BufReadPost', {
group = group,
callback = function(ev)
M.check_pending_restore(ev.buf)
end
})
-- Auto-restore on VimEnter
vim.api.nvim_create_autocmd('VimEnter', {
group = group,
once = true,
callback = function()
-- Delay slightly to ensure everything is loaded
vim.defer_fn(function()
M.restore_diffs()
end, 100)
end
})
end
return M

File diff suppressed because it is too large Load Diff

@ -1,113 +0,0 @@
-- 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' },
i = { 'List files with diffs' },
},
['<leader>i'] = {
name = 'Inline Diffs',
a = { 'Accept current hunk' },
r = { 'Reject current hunk' },
A = { 'Accept all hunks in file' },
R = { 'Reject all hunks in file' },
AA = { 'Accept ALL diffs in ALL files' },
q = { 'Close inline diff' },
l = { 'List files with diffs' },
}
})
end
-- Global keymaps for navigating between files with Claude diffs
vim.keymap.set('n', ']f', function()
local inline_diff = require('nvim-claude.inline-diff')
inline_diff.next_diff_file()
end, {
desc = 'Next file with Claude diff',
silent = true
})
vim.keymap.set('n', '[f', function()
local inline_diff = require('nvim-claude.inline-diff')
inline_diff.prev_diff_file()
end, {
desc = 'Previous file with Claude diff',
silent = true
})
-- Global keymap for listing files with diffs
vim.keymap.set('n', prefix .. 'i', function()
local inline_diff = require('nvim-claude.inline-diff')
inline_diff.list_diff_files()
end, {
desc = 'List files with Claude diffs',
silent = true
})
-- Global keymap to accept all diffs across all files
vim.keymap.set('n', '<leader>iAA', function()
local inline_diff = require('nvim-claude.inline-diff')
inline_diff.accept_all_files()
end, {
desc = 'Accept ALL Claude diffs in ALL files',
silent = true
})
end
return M

@ -1,248 +0,0 @@
-- 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()
vim.notify('registry.load() called', vim.log.levels.DEBUG)
local content = utils.read_file(M.registry_path)
if content then
vim.notify(string.format('registry.load: Read %d bytes from %s', #content, M.registry_path), vim.log.levels.DEBUG)
local ok, data = pcall(vim.json.decode, content)
if ok and type(data) == 'table' then
local agent_count = vim.tbl_count(data)
vim.notify(string.format('registry.load: Decoded %d agents from JSON', agent_count), vim.log.levels.DEBUG)
M.agents = data
M.validate_agents()
else
vim.notify('registry.load: Failed to decode JSON, clearing agents', vim.log.levels.WARN)
M.agents = {}
end
else
vim.notify('registry.load: No content read from file, clearing agents', vim.log.levels.WARN)
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
local mission_log_path = agent.work_dir .. '/mission.log'
local mission_exists = utils.file_exists(mission_log_path)
if mission_exists then
-- Check if tmux window still exists
local window_exists = M.check_window_exists(agent.window_id)
if window_exists then
agent.status = 'active'
-- Update progress from file for active agents
local progress_file = agent.work_dir .. '/progress.txt'
local progress_content = utils.read_file(progress_file)
if progress_content and progress_content ~= '' then
agent.progress = progress_content:gsub('\n$', '') -- Remove trailing newline
end
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, fork_info)
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(),
progress = 'Starting...', -- Add progress field
last_update = os.time(),
fork_info = fork_info, -- Store branch/stash info
}
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()
-- Ensure registry is loaded
if not M.agents or vim.tbl_isempty(M.agents) then
M.load()
end
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
-- Include the registry ID with the agent
agent._registry_id = id
table.insert(project_agents, 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.agents[id].last_update = os.time()
M.save()
end
end
-- Update agent progress
function M.update_progress(id, progress)
if M.agents[id] then
M.agents[id].progress = progress
M.agents[id].last_update = os.time()
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
local progress_str = ''
if agent.progress and agent.status == 'active' then
progress_str = string.format(' | %s', agent.progress)
end
-- Clean up task to single line
local task_line = agent.task:match('[^\n]*') or agent.task
local task_preview = task_line:sub(1, 50) .. (task_line:len() > 50 and '...' or '')
return string.format(
'[%s] %s (%s) - %s%s',
agent.status:upper(),
task_preview,
age_str,
agent.window_name or 'unknown',
progress_str
)
end
return M

@ -1,112 +0,0 @@
local M = {}
local utils = require('nvim-claude.utils')
-- Update Claude settings with current Neovim server address
function M.update_claude_settings()
local project_root = utils.get_project_root()
if not project_root then
return
end
local settings_path = project_root .. '/.claude/settings.json'
local settings_dir = project_root .. '/.claude'
-- Get current Neovim server address
local server_addr = vim.v.servername
if not server_addr or server_addr == '' then
-- If no servername, we can't communicate
return
end
-- Ensure .claude directory exists
if vim.fn.isdirectory(settings_dir) == 0 then
vim.fn.mkdir(settings_dir, 'p')
end
-- Read existing settings or create new
local settings = {}
if vim.fn.filereadable(settings_path) == 1 then
local ok, content = pcall(vim.fn.readfile, settings_path)
if ok and #content > 0 then
local decode_ok, decoded = pcall(vim.json.decode, table.concat(content, '\n'))
if decode_ok then
settings = decoded
end
end
end
-- Ensure hooks structure exists
if not settings.hooks then
settings.hooks = {}
end
if not settings.hooks.PreToolUse then
settings.hooks.PreToolUse = {}
end
if not settings.hooks.PostToolUse then
settings.hooks.PostToolUse = {}
end
-- Update hook commands with current server address
local pre_hook_cmd = string.format(
'nvr --servername "%s" --remote-expr \'luaeval("require(\\"nvim-claude.hooks\\").pre_tool_use_hook()")\'',
server_addr
)
local post_hook_cmd = string.format(
'nvr --servername "%s" --remote-send "<C-\\\\><C-N>:lua require(\'nvim-claude.hooks\').post_tool_use_hook()<CR>"',
server_addr
)
-- Update PreToolUse hooks
local pre_hook_found = false
for _, hook_group in ipairs(settings.hooks.PreToolUse) do
if hook_group.matcher == "Edit|Write|MultiEdit" then
hook_group.hooks = {{type = "command", command = pre_hook_cmd}}
pre_hook_found = true
break
end
end
if not pre_hook_found then
table.insert(settings.hooks.PreToolUse, {
matcher = "Edit|Write|MultiEdit",
hooks = {{type = "command", command = pre_hook_cmd}}
})
end
-- Update PostToolUse hooks
local post_hook_found = false
for _, hook_group in ipairs(settings.hooks.PostToolUse) do
if hook_group.matcher == "Edit|Write|MultiEdit" then
hook_group.hooks = {{type = "command", command = post_hook_cmd}}
post_hook_found = true
break
end
end
if not post_hook_found then
table.insert(settings.hooks.PostToolUse, {
matcher = "Edit|Write|MultiEdit",
hooks = {{type = "command", command = post_hook_cmd}}
})
end
-- Write updated settings
local encoded = vim.json.encode(settings)
vim.fn.writefile({encoded}, settings_path)
end
-- Setup autocmds to update settings
function M.setup()
vim.api.nvim_create_autocmd({"VimEnter", "DirChanged"}, {
group = vim.api.nvim_create_augroup("NvimClaudeSettingsUpdater", { clear = true }),
callback = function()
-- Defer to ensure servername is available
vim.defer_fn(function()
M.update_claude_settings()
end, 100)
end,
})
end
return M

@ -1,67 +0,0 @@
-- Statusline components for nvim-claude
local M = {}
-- Get active agent count and summary
function M.get_agent_status()
local registry = require('nvim-claude.registry')
-- Validate agents to update their status
registry.validate_agents()
local agents = registry.get_project_agents()
local active_count = 0
local latest_progress = nil
local latest_task = nil
for _, agent in ipairs(agents) do
if agent.status == 'active' then
active_count = active_count + 1
-- Get the most recently updated active agent
if not latest_progress or (agent.last_update and agent.last_update > (latest_progress.last_update or 0)) then
latest_progress = agent.progress
latest_task = agent.task
end
end
end
if active_count == 0 then
return ''
elseif active_count == 1 and latest_progress then
-- Show single agent progress
local task_short = latest_task
if #latest_task > 20 then
task_short = latest_task:sub(1, 17) .. '...'
end
return string.format('🤖 %s: %s', task_short, latest_progress)
else
-- Show count of multiple agents
return string.format('🤖 %d agents', active_count)
end
end
-- Lualine component
function M.lualine_component()
return {
M.get_agent_status,
cond = function()
-- Only show if there are active agents
local status = M.get_agent_status()
return status ~= ''
end,
on_click = function()
-- Open agent list on click
vim.cmd('ClaudeAgents')
end,
}
end
-- Simple string function for custom statuslines
function M.statusline()
local status = M.get_agent_status()
if status ~= '' then
return ' ' .. status .. ' '
end
return ''
end
return M

@ -1,271 +0,0 @@
-- 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

@ -1,151 +0,0 @@
-- Utility functions for nvim-claude
-- TEST EDIT #2: Testing multi-file accept
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 ~= nil
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
-- Write JSON to file
function M.write_json(path, data)
local success, json = pcall(vim.fn.json_encode, data)
if not success then
return false, 'Failed to encode JSON: ' .. json
end
local file = io.open(path, 'w')
if not file then
return false, 'Failed to open file for writing: ' .. path
end
-- Pretty print JSON
local formatted = json:gsub('},{', '},\n {'):gsub('\\{', '{\n '):gsub('\\}', '\n}')
file:write(formatted)
file:close()
return true, nil
end
-- Read JSON from file
function M.read_json(path)
local content = M.read_file(path)
if not content then
return nil, 'Failed to read file: ' .. path
end
local success, data = pcall(vim.fn.json_decode, content)
if not success then
return nil, 'Failed to decode JSON: ' .. data
end
return data, nil
end
return M
Loading…
Cancel
Save