From 3ea8f3be130af97026c80c9b1a13110e3e80b0d8 Mon Sep 17 00:00:00 2001 From: Marcin Jahn <10273406+marcinjahn@users.noreply.github.com> Date: Sat, 2 Aug 2025 23:36:48 +0200 Subject: [PATCH 1/8] feat: add provider: external to run Claude in separate terminal I think it's pretty convenient to have Claude running in a separate window, separate from Neovim window. I think this is particularly useful on tiling window managers. --- CLAUDE.md | 26 ++++- lua/claudecode/config.lua | 16 +++ lua/claudecode/terminal.lua | 45 +++++++- lua/claudecode/terminal/external.lua | 150 +++++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 lua/claudecode/terminal/external.lua diff --git a/CLAUDE.md b/CLAUDE.md index 45a6262..e3cc2a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,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 @@ -105,6 +105,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 @@ -314,13 +336,11 @@ When updating the version number for a new release, you must update **ALL** of t ``` 2. **`scripts/claude_interactive.sh`** - Multiple client version references: - - Line ~52: `"version": "0.2.0"` (handshake) - Line ~223: `"version": "0.2.0"` (initialize) - Line ~309: `"version": "0.2.0"` (reconnect) 3. **`scripts/lib_claude.sh`** - ClaudeCodeNvim version: - - Line ~120: `"version": "0.2.0"` (init message) 4. **`CHANGELOG.md`** - Add new release section with: diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 88bdee8..11d26e0 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -23,6 +23,9 @@ M.defaults = { { name = "Claude Opus 4 (Latest)", value = "opus" }, { name = "Claude Sonnet 4 (Latest)", value = "sonnet" }, }, + terminal = { + external_terminal_cmd = nil, -- Command template for external terminal (e.g., "alacritty -e %s") + }, } --- Validates the provided configuration table. @@ -44,6 +47,19 @@ 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") + assert( + config.terminal.external_terminal_cmd == nil or type(config.terminal.external_terminal_cmd) == "string", + "terminal.external_terminal_cmd must be nil or a string" + ) + if config.terminal.external_terminal_cmd and config.terminal.external_terminal_cmd ~= "" then + assert( + config.terminal.external_terminal_cmd:find("%%s"), + "terminal.external_terminal_cmd must contain '%s' placeholder for the Claude command" + ) + 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 4d4ee94..388a38a 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -141,6 +141,22 @@ local function get_provider() else logger.warn("terminal", "'snacks' provider configured, but Snacks.nvim not available. Falling back to 'native'.") end + elseif config.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 has_external_cmd = config.external_terminal_cmd + and config.external_terminal_cmd ~= "" + and config.external_terminal_cmd:find("%%s") + if has_external_cmd then + return external_provider + else + logger.warn( + "terminal", + "'external' provider configured, but external_terminal_cmd not properly set. Falling back to 'native'." + ) + end + end elseif config.provider == "native" then -- noop, will use native provider as default below logger.debug("terminal", "Using native terminal provider") @@ -302,12 +318,26 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) end for k, v in pairs(user_term_config) do - if config[k] ~= nil and k ~= "terminal_cmd" then -- terminal_cmd is handled above + if k == "terminal_cmd" then + -- terminal_cmd is handled above, skip + elseif k == "external_terminal_cmd" then + -- Handle external_terminal_cmd specially + if v == nil or type(v) == "string" then + config[k] = v + else + vim.notify( + "claudecode.terminal.setup: Invalid value for external_terminal_cmd: " .. tostring(v), + vim.log.levels.WARN + ) + end + elseif config[k] ~= nil then -- Other known config keys if k == "split_side" and (v == "left" or v == "right") then config[k] = v elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then config[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 config[k] = v elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then config[k] = v @@ -318,13 +348,20 @@ 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 -- Setup providers with config - get_provider().setup(config) + -- Convert flat config to nested structure for external provider compatibility + local provider_config = vim.deepcopy(config) + if config.external_terminal_cmd then + provider_config.terminal = provider_config.terminal or {} + provider_config.terminal.external_terminal_cmd = config.external_terminal_cmd + end + + get_provider().setup(provider_config) end --- Opens or focuses the Claude terminal. diff --git a/lua/claudecode/terminal/external.lua b/lua/claudecode/terminal/external.lua new file mode 100644 index 0000000..d8b95bc --- /dev/null +++ b/lua/claudecode/terminal/external.lua @@ -0,0 +1,150 @@ +--- External terminal provider for Claude Code. +-- Launches Claude Code in an external terminal application using a user-specified command. +-- @module claudecode.terminal.external + +--- @type TerminalProvider +local M = {} + +local logger = require("claudecode.logger") +local utils = require("claudecode.utils") + +local jobid = nil +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 table +function M.setup(term_config) + config = term_config or {} +end + +--- @param cmd_string string +--- @param env_table table +--- @param effective_config table +--- @param focus boolean|nil +function M.open(cmd_string, env_table, effective_config, focus) + focus = utils.normalize_focus(focus) + + 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 + + -- Build the external command using the configured template + if + not config.terminal + or not config.terminal.external_terminal_cmd + or config.terminal.external_terminal_cmd == "" + then + vim.notify( + "terminal.external_terminal_cmd not configured. Please set terminal.external_terminal_cmd in your config.", + vim.log.levels.ERROR + ) + return + end + + -- Replace %s in the template with the Claude command + if not config.terminal.external_terminal_cmd:find("%%s") then + vim.notify( + "terminal.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(config.terminal.external_terminal_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|nil +function M.get_active_bufnr() + -- External terminals don't have associated Neovim buffers + return nil +end + +--- @return boolean +function M.is_available() + -- Availability is checked by terminal.lua before this provider is selected + return true +end + +--- @return table|nil +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 From ab832486c046e537edb23aa89111f6afb31ad40c Mon Sep 17 00:00:00 2001 From: Marcin Jahn <10273406+marcinjahn@users.noreply.github.com> Date: Tue, 5 Aug 2025 19:28:19 +0200 Subject: [PATCH 2/8] Update lua/claudecode/terminal/external.lua Co-authored-by: Thomas Kosiewski --- lua/claudecode/terminal/external.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/claudecode/terminal/external.lua b/lua/claudecode/terminal/external.lua index d8b95bc..8e717e6 100644 --- a/lua/claudecode/terminal/external.lua +++ b/lua/claudecode/terminal/external.lua @@ -1,6 +1,6 @@ --- External terminal provider for Claude Code. --- Launches Claude Code in an external terminal application using a user-specified command. --- @module claudecode.terminal.external +---Launches Claude Code in an external terminal application using a user-specified command. +---@module 'claudecode.terminal.external' --- @type TerminalProvider local M = {} From 57cf254c749dbc2bc21d7944ec0967ea3112bd7b Mon Sep 17 00:00:00 2001 From: Marcin Jahn <10273406+marcinjahn@users.noreply.github.com> Date: Tue, 5 Aug 2025 19:28:27 +0200 Subject: [PATCH 3/8] Update lua/claudecode/terminal/external.lua Co-authored-by: Thomas Kosiewski --- lua/claudecode/terminal/external.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/claudecode/terminal/external.lua b/lua/claudecode/terminal/external.lua index 8e717e6..5843353 100644 --- a/lua/claudecode/terminal/external.lua +++ b/lua/claudecode/terminal/external.lua @@ -21,7 +21,7 @@ local function is_valid() return jobid and jobid > 0 end ---- @param term_config table +--- @param term_config TerminalConfig function M.setup(term_config) config = term_config or {} end From 30576eb761122287ec837a194e9cce5c7effc95a Mon Sep 17 00:00:00 2001 From: Marcin Jahn <10273406+marcinjahn@users.noreply.github.com> Date: Tue, 5 Aug 2025 19:28:33 +0200 Subject: [PATCH 4/8] Update lua/claudecode/terminal/external.lua Co-authored-by: Thomas Kosiewski --- lua/claudecode/terminal/external.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/claudecode/terminal/external.lua b/lua/claudecode/terminal/external.lua index 5843353..b794211 100644 --- a/lua/claudecode/terminal/external.lua +++ b/lua/claudecode/terminal/external.lua @@ -9,7 +9,8 @@ local logger = require("claudecode.logger") local utils = require("claudecode.utils") local jobid = nil -local config = {} +---@type TerminalConfig +local config local function cleanup_state() jobid = nil From 4a84caa54a3aa3f381171ae04f2d1ca92b030332 Mon Sep 17 00:00:00 2001 From: Marcin Jahn <10273406+marcinjahn@users.noreply.github.com> Date: Tue, 5 Aug 2025 19:49:52 +0200 Subject: [PATCH 5/8] Fix annotations --- lua/claudecode/terminal/external.lua | 48 ++++++++++++---------------- lua/claudecode/types.lua | 7 +++- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/lua/claudecode/terminal/external.lua b/lua/claudecode/terminal/external.lua index dd45121..edc74ae 100644 --- a/lua/claudecode/terminal/external.lua +++ b/lua/claudecode/terminal/external.lua @@ -2,14 +2,14 @@ ---Launches Claude Code in an external terminal application using a user-specified command. ---@module 'claudecode.terminal.external' ---- @type TerminalProvider +---@type ClaudeCodeTerminalProvider local M = {} local logger = require("claudecode.logger") local utils = require("claudecode.utils") local jobid = nil ----@type TerminalConfig +---@type ClaudeCodeTerminalConfig local config local function cleanup_state() @@ -22,15 +22,15 @@ local function is_valid() return jobid and jobid > 0 end ---- @param term_config TerminalConfig +---@param term_config ClaudeCodeTerminalConfig function M.setup(term_config) config = term_config or {} end ---- @param cmd_string string ---- @param env_table table ---- @param effective_config table ---- @param focus boolean|nil +---@param cmd_string string +---@param env_table table +---@param effective_config table +---@param focus boolean? function M.open(cmd_string, env_table, effective_config, focus) focus = utils.normalize_focus(focus) @@ -54,10 +54,7 @@ function M.open(cmd_string, env_table, effective_config, focus) -- 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 - ) + vim.notify("external_terminal_cmd must contain '%s' placeholder for the Claude command.", vim.log.levels.ERROR) return end @@ -94,9 +91,9 @@ function M.close() end --- Simple toggle: always start external terminal (can't hide external terminals) ---- @param cmd_string string ---- @param env_table table ---- @param effective_config table +---@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 @@ -108,9 +105,9 @@ function M.simple_toggle(cmd_string, env_table, effective_config) end --- Smart focus toggle: same as simple toggle for external terminals ---- @param cmd_string string ---- @param env_table table ---- @param effective_config table +---@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 @@ -118,32 +115,29 @@ function M.focus_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 +---@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|nil +---@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() - -- For external terminals, we can't control window visibility - -- This is a no-op to prevent unnecessary buffer searches -end +function M.ensure_visible() end ---- @return boolean +---@return boolean function M.is_available() -- Availability is checked by terminal.lua before this provider is selected return true end ---- @return table|nil +---@return table? function M._get_terminal_for_test() -- For testing purposes, return job info if available if is_valid() then 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 From 09c244748d0bed5238ad860696703b12414f972e Mon Sep 17 00:00:00 2001 From: Marcin Jahn <10273406+marcinjahn@users.noreply.github.com> Date: Tue, 5 Aug 2025 19:58:46 +0200 Subject: [PATCH 6/8] fix formatting --- lua/claudecode/terminal.lua | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 68ddce9..816af8e 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -142,10 +142,8 @@ local function get_provider() 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") + + local has_external_cmd = external_cmd and external_cmd ~= "" and external_cmd:find("%%s") if has_external_cmd then return external_provider else @@ -343,10 +341,7 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) end end else - vim.notify( - "claudecode.terminal.setup: Invalid value for provider_opts: " .. tostring(v), - vim.log.levels.WARN - ) + 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 From ac1feb838d9b3ea693e677c5d9b2eafc9a721173 Mon Sep 17 00:00:00 2001 From: Marcin Jahn <10273406+marcinjahn@users.noreply.github.com> Date: Tue, 5 Aug 2025 20:19:28 +0200 Subject: [PATCH 7/8] Update README.md --- README.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 09aa20f..fda982e 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: From 05a95f6733106acea35ff98e0d47945de7935ad1 Mon Sep 17 00:00:00 2001 From: Marcin Jahn <10273406+marcinjahn@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:54:03 +0200 Subject: [PATCH 8/8] Update lua/claudecode/config.lua Co-authored-by: Thomas Kosiewski --- lua/claudecode/config.lua | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 09dcfaa..111560c 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -29,11 +29,7 @@ M.defaults = { { name = "Claude Opus 4 (Latest)", value = "opus" }, { name = "Claude Sonnet 4 (Latest)", value = "sonnet" }, }, - terminal = { - provider_opts = { - external_terminal_cmd = nil, -- Command template for external terminal (e.g., "alacritty -e %s") - }, - }, + terminal = nil, -- Will be lazy-loaded to avoid circular dependency } ---Validates the provided configuration table.