diff --git a/CLAUDE.md b/CLAUDE.md index 8224cc1..b396b7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ The `fixtures/` directory contains test Neovim configurations for verifying plug 3. **Lock File System** (`lua/claudecode/lockfile.lua`) - Creates discovery files for Claude CLI at `~/.claude/ide/` 4. **Selection Tracking** (`lua/claudecode/selection.lua`) - Monitors text selections and sends updates to Claude 5. **Diff Integration** (`lua/claudecode/diff.lua`) - Native Neovim diff support for Claude's file comparisons -6. **Terminal Integration** (`lua/claudecode/terminal.lua`) - Manages Claude CLI terminal sessions +6. **Terminal Integration** (`lua/claudecode/terminal.lua`) - Manages Claude CLI terminal sessions with support for internal Neovim terminals and external terminal applications ### WebSocket Server Implementation @@ -106,6 +106,28 @@ The WebSocket server implements secure authentication using: **Format Compliance**: All tools return MCP-compliant format: `{content: [{type: "text", text: "JSON-stringified-data"}]}` +### Terminal Integration Options + +**Internal Terminals** (within Neovim): + +- **Snacks.nvim**: `terminal/snacks.lua` - Advanced terminal with floating windows +- **Native**: `terminal/native.lua` - Built-in Neovim terminal as fallback + +**External Terminals** (separate applications): + +- **External Provider**: `terminal/external.lua` - Launches Claude in external terminal apps + +**Configuration Example**: + +```lua +opts = { + terminal = { + provider = "external", -- "auto", "snacks", "native", or "external" + external_terminal_cmd = "alacritty -e %s" -- Required for external provider + } +} +``` + ### Key File Locations - `lua/claudecode/init.lua` - Main entry point and setup diff --git a/README.md b/README.md index 09aa20f..0774826 100644 --- a/README.md +++ b/README.md @@ -257,9 +257,14 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). terminal = { split_side = "right", -- "left" or "right" split_width_percentage = 0.30, - provider = "auto", -- "auto", "snacks", "native", or custom provider table + provider = "auto", -- "auto", "snacks", "native", "external", or custom provider table auto_close = true, snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below + + -- Provider-specific options + provider_opts = { + external_terminal_cmd = nil, -- Command template for external terminal provider (e.g., "alacritty -e %s") + }, }, -- Diff Integration @@ -440,7 +445,27 @@ For complete configuration options, see: - [Snacks.nvim Terminal Documentation](https://github.com/folke/snacks.nvim/blob/main/docs/terminal.md) - [Snacks.nvim Window Documentation](https://github.com/folke/snacks.nvim/blob/main/docs/win.md) -## Custom Terminal Providers +## Terminal Providers + +### External Terminal Provider + +Run Claude Code in a separate terminal application outside of Neovim: + +```lua +{ + "coder/claudecode.nvim", + opts = { + terminal = { + provider = "external", + provider_opts = { + external_terminal_cmd = "alacritty -e %s", -- Replace with your preferred terminal program. %s is replaced with claude command + }, + }, + }, +} +``` + +### Custom Terminal Providers You can create custom terminal providers by passing a table with the required functions instead of a string provider name: diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index d7b027c..5676781 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -52,6 +52,28 @@ function M.validate(config) assert(config.terminal_cmd == nil or type(config.terminal_cmd) == "string", "terminal_cmd must be nil or a string") + -- Validate terminal config + assert(type(config.terminal) == "table", "terminal must be a table") + + -- Validate provider_opts if present + if config.terminal.provider_opts then + assert(type(config.terminal.provider_opts) == "table", "terminal.provider_opts must be a table") + + -- Validate external_terminal_cmd in provider_opts + if config.terminal.provider_opts.external_terminal_cmd then + assert( + type(config.terminal.provider_opts.external_terminal_cmd) == "string", + "terminal.provider_opts.external_terminal_cmd must be a string" + ) + if config.terminal.provider_opts.external_terminal_cmd ~= "" then + assert( + config.terminal.provider_opts.external_terminal_cmd:find("%%s"), + "terminal.provider_opts.external_terminal_cmd must contain '%s' placeholder for the Claude command" + ) + end + end + end + local valid_log_levels = { "trace", "debug", "info", "warn", "error" } local is_valid_log_level = false for _, level in ipairs(valid_log_levels) do diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index dce77c8..65273db 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -13,6 +13,9 @@ local defaults = { provider = "auto", show_native_term_exit_tip = true, terminal_cmd = nil, + provider_opts = { + external_terminal_cmd = nil, + }, auto_close = true, env = {}, snacks_win_opts = {}, @@ -134,6 +137,22 @@ local function get_provider() else logger.warn("terminal", "'snacks' provider configured, but Snacks.nvim not available. Falling back to 'native'.") end + elseif defaults.provider == "external" then + local external_provider = load_provider("external") + if external_provider then + -- Check availability based on our config instead of provider's internal state + local external_cmd = defaults.provider_opts and defaults.provider_opts.external_terminal_cmd + + local has_external_cmd = external_cmd and external_cmd ~= "" and external_cmd:find("%%s") + if has_external_cmd then + return external_provider + else + logger.warn( + "terminal", + "'external' provider configured, but provider_opts.external_terminal_cmd not properly set. Falling back to 'native'." + ) + end + end elseif defaults.provider == "native" then -- noop, will use native provider as default below logger.debug("terminal", "Using native terminal provider") @@ -300,12 +319,39 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) end for k, v in pairs(user_term_config) do - if defaults[k] ~= nil and k ~= "terminal_cmd" then -- terminal_cmd is handled above + if k == "terminal_cmd" then + -- terminal_cmd is handled above, skip + break + elseif k == "provider_opts" then + -- Handle nested provider options + if type(v) == "table" then + defaults[k] = defaults[k] or {} + for opt_k, opt_v in pairs(v) do + if opt_k == "external_terminal_cmd" then + if opt_v == nil or type(opt_v) == "string" then + defaults[k][opt_k] = opt_v + else + vim.notify( + "claudecode.terminal.setup: Invalid value for provider_opts.external_terminal_cmd: " .. tostring(opt_v), + vim.log.levels.WARN + ) + end + else + -- For other provider options, just copy them + defaults[k][opt_k] = opt_v + end + end + else + vim.notify("claudecode.terminal.setup: Invalid value for provider_opts: " .. tostring(v), vim.log.levels.WARN) + end + elseif defaults[k] ~= nil then -- Other known config keys if k == "split_side" and (v == "left" or v == "right") then defaults[k] = v elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then defaults[k] = v - elseif k == "provider" and (v == "snacks" or v == "native" or v == "auto" or type(v) == "table") then + elseif + k == "provider" and (v == "snacks" or v == "native" or v == "external" or v == "auto" or type(v) == "table") + then defaults[k] = v elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then defaults[k] = v @@ -316,7 +362,7 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) else vim.notify("claudecode.terminal.setup: Invalid value for " .. k .. ": " .. tostring(v), vim.log.levels.WARN) end - elseif k ~= "terminal_cmd" then -- Avoid warning for terminal_cmd if passed in user_term_config + else vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN) end end diff --git a/lua/claudecode/terminal/external.lua b/lua/claudecode/terminal/external.lua new file mode 100644 index 0000000..8c960e5 --- /dev/null +++ b/lua/claudecode/terminal/external.lua @@ -0,0 +1,144 @@ +--- External terminal provider for Claude Code. +---Launches Claude Code in an external terminal application using a user-specified command. +---@module 'claudecode.terminal.external' + +---@type ClaudeCodeTerminalProvider +local M = {} + +local logger = require("claudecode.logger") + +local jobid = nil +---@type ClaudeCodeTerminalConfig +local config + +local function cleanup_state() + jobid = nil +end + +local function is_valid() + -- For external terminals, we only track if we have a running job + -- We don't manage terminal windows since they're external + return jobid and jobid > 0 +end + +---@param term_config ClaudeCodeTerminalConfig +function M.setup(term_config) + config = term_config or {} +end + +---@param cmd_string string +---@param env_table table +function M.open(cmd_string, env_table) + if is_valid() then + -- External terminal is already running, we can't focus it programmatically + -- Just log that it's already running + logger.debug("terminal", "External Claude terminal is already running") + return + end + + -- Get external terminal command from provider_opts + local external_cmd = config.provider_opts and config.provider_opts.external_terminal_cmd + + if not external_cmd or external_cmd == "" then + vim.notify( + "external_terminal_cmd not configured. Please set terminal.provider_opts.external_terminal_cmd in your config.", + vim.log.levels.ERROR + ) + return + end + + -- Replace %s in the template with the Claude command + if not external_cmd:find("%%s") then + vim.notify("external_terminal_cmd must contain '%s' placeholder for the Claude command.", vim.log.levels.ERROR) + return + end + + -- Build command by replacing %s with Claude command and splitting + local full_command = string.format(external_cmd, cmd_string) + local cmd_parts = vim.split(full_command, " ") + + -- Start the external terminal as a detached process + jobid = vim.fn.jobstart(cmd_parts, { + detach = true, + env = env_table, + on_exit = function(job_id, exit_code, _) + vim.schedule(function() + if job_id == jobid then + cleanup_state() + end + end) + end, + }) + + if not jobid or jobid <= 0 then + vim.notify("Failed to start external terminal with command: " .. full_command, vim.log.levels.ERROR) + cleanup_state() + return + end +end + +function M.close() + if is_valid() then + -- Try to stop the job gracefully + vim.fn.jobstop(jobid) + cleanup_state() + end +end + +--- Simple toggle: always start external terminal (can't hide external terminals) +---@param cmd_string string +---@param env_table table +---@param effective_config table +function M.simple_toggle(cmd_string, env_table, effective_config) + if is_valid() then + -- External terminal is running, stop it + M.close() + else + -- Start external terminal + M.open(cmd_string, env_table, effective_config, true) + end +end + +--- Smart focus toggle: same as simple toggle for external terminals +---@param cmd_string string +---@param env_table table +---@param effective_config table +function M.focus_toggle(cmd_string, env_table, effective_config) + -- For external terminals, focus toggle behaves the same as simple toggle + -- since we can't detect or control focus of external windows + M.simple_toggle(cmd_string, env_table, effective_config) +end + +--- Legacy toggle function for backward compatibility +---@param cmd_string string +---@param env_table table +---@param effective_config table +function M.toggle(cmd_string, env_table, effective_config) + M.simple_toggle(cmd_string, env_table, effective_config) +end + +---@return number? +function M.get_active_bufnr() + -- External terminals don't have associated Neovim buffers + return nil +end + +--- No-op function for external terminals since we can't ensure visibility of external windows +function M.ensure_visible() end + +---@return boolean +function M.is_available() + -- Availability is checked by terminal.lua before this provider is selected + return true +end + +---@return table? +function M._get_terminal_for_test() + -- For testing purposes, return job info if available + if is_valid() then + return { jobid = jobid } + end + return nil +end + +return M diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index c825d25..2dad779 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -32,7 +32,11 @@ ---@alias ClaudeCodeSplitSide "left"|"right" -- In-tree terminal provider names ----@alias ClaudeCodeTerminalProviderName "auto"|"snacks"|"native" +---@alias ClaudeCodeTerminalProviderName "auto"|"snacks"|"native"|"external" + +-- Terminal provider-specific options +---@class ClaudeCodeTerminalProviderOptions +---@field external_terminal_cmd string? Command template for external terminal (e.g., "alacritty -e %s") -- @ mention queued for Claude Code ---@class ClaudeCodeMention @@ -61,6 +65,7 @@ ---@field provider ClaudeCodeTerminalProviderName|ClaudeCodeTerminalProvider ---@field show_native_term_exit_tip boolean ---@field terminal_cmd string? +---@field provider_opts ClaudeCodeTerminalProviderOptions? ---@field auto_close boolean ---@field env table ---@field snacks_win_opts table