You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
kickstart.nvim/lua/nvim-claude/tmux.lua

271 lines
7.9 KiB
Lua

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