diff --git a/README.md b/README.md index d72fcfe2..8da67ee3 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ 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", "external", or custom provider table + provider = "auto", -- "auto", "snacks", "native", "external", "none", or custom provider table auto_close = true, snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below @@ -488,6 +488,26 @@ For complete configuration options, see: ## Terminal Providers +### None (No-Op) Provider + +Run Claude Code without any terminal management inside Neovim. This is useful for advanced setups where you manage the CLI externally (tmux, kitty, separate terminal windows) while still using the WebSocket server and tools. + +```lua +{ + "coder/claudecode.nvim", + opts = { + terminal = { + provider = "none", -- no UI actions; server + tools remain available + }, + }, +} +``` + +Notes: + +- No windows/buffers are created. `:ClaudeCode` and related commands will not open anything. +- The WebSocket server still starts and broadcasts work as usual. Launch the Claude CLI externally when desired. + ### External Terminal Provider Run Claude Code in a separate terminal application outside of Neovim: diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index d27d9cb4..fae0b30f 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -166,6 +166,14 @@ local function get_provider() elseif defaults.provider == "native" then -- noop, will use native provider as default below logger.debug("terminal", "Using native terminal provider") + elseif defaults.provider == "none" then + local none_provider = load_provider("none") + if none_provider then + logger.debug("terminal", "Using no-op terminal provider ('none')") + return none_provider + else + logger.warn("terminal", "'none' provider configured but failed to load. Falling back to 'native'.") + end elseif type(defaults.provider) == "string" then logger.warn( "terminal", @@ -394,7 +402,7 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) ) end elseif k == "provider" then - if type(v) == "table" or v == "snacks" or v == "native" or v == "external" or v == "auto" then + if type(v) == "table" or v == "snacks" or v == "native" or v == "external" or v == "auto" or v == "none" then defaults.provider = v else vim.notify( diff --git a/lua/claudecode/terminal/none.lua b/lua/claudecode/terminal/none.lua new file mode 100644 index 00000000..a1bcea34 --- /dev/null +++ b/lua/claudecode/terminal/none.lua @@ -0,0 +1,74 @@ +--- No-op terminal provider for Claude Code. +--- Performs zero UI actions and never manages terminals inside Neovim. +---@module 'claudecode.terminal.none' + +---@type ClaudeCodeTerminalProvider +local M = {} + +---Stored config (not used, but kept for parity with other providers) +---Setup the no-op provider +---@param term_config ClaudeCodeTerminalConfig +function M.setup(term_config) + -- intentionally no-op +end + +---Open terminal (no-op) +---@param cmd_string string +---@param env_table table +---@param effective_config ClaudeCodeTerminalConfig +---@param focus boolean|nil +function M.open(cmd_string, env_table, effective_config, focus) + -- intentionally no-op +end + +---Close terminal (no-op) +function M.close() + -- intentionally no-op +end + +---Simple toggle (no-op) +---@param cmd_string string +---@param env_table table +---@param effective_config ClaudeCodeTerminalConfig +function M.simple_toggle(cmd_string, env_table, effective_config) + -- intentionally no-op +end + +---Focus toggle (no-op) +---@param cmd_string string +---@param env_table table +---@param effective_config ClaudeCodeTerminalConfig +function M.focus_toggle(cmd_string, env_table, effective_config) + -- intentionally no-op +end + +---Legacy toggle (no-op) +---@param cmd_string string +---@param env_table table +---@param effective_config ClaudeCodeTerminalConfig +function M.toggle(cmd_string, env_table, effective_config) + -- intentionally no-op +end + +---Ensure visible (no-op) +function M.ensure_visible() end + +---Return active buffer number (always nil) +---@return number|nil +function M.get_active_bufnr() + return nil +end + +---Provider availability (always true; explicit opt-in required) +---@return boolean +function M.is_available() + return true +end + +---Testing hook (no state to return) +---@return table|nil +function M._get_terminal_for_test() + return nil +end + +return M diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index b5b3a2b0..2acc365c 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -39,11 +39,11 @@ ---@alias ClaudeCodeSplitSide "left"|"right" -- In-tree terminal provider names ----@alias ClaudeCodeTerminalProviderName "auto"|"snacks"|"native"|"external" +---@alias ClaudeCodeTerminalProviderName "auto"|"snacks"|"native"|"external"|"none" -- Terminal provider-specific options ---@class ClaudeCodeTerminalProviderOptions ----@field external_terminal_cmd string|fun(cmd: string, env: table): string|table|nil Command for external terminal (string template with %s or function) +---@field external_terminal_cmd string|(fun(cmd: string, env: table): string)|table|nil Command for external terminal (string template with %s or function) -- Working directory resolution context and provider ---@class ClaudeCodeCwdContext diff --git a/tests/unit/terminal/none_provider_spec.lua b/tests/unit/terminal/none_provider_spec.lua new file mode 100644 index 00000000..ef90e0b1 --- /dev/null +++ b/tests/unit/terminal/none_provider_spec.lua @@ -0,0 +1,70 @@ +require("tests.busted_setup") +require("tests.mocks.vim") + +describe("none terminal provider", function() + local terminal + + local termopen_calls + local jobstart_calls + + before_each(function() + -- Prepare vim.fn helpers used by terminal module + vim.fn = vim.fn or {} + vim.fn.getcwd = function() + return "/mock/cwd" + end + vim.fn.expand = function(val) + return val + end + + -- Spy-able termopen/jobstart that count invocations + termopen_calls = 0 + jobstart_calls = 0 + vim.fn.termopen = function(...) + termopen_calls = termopen_calls + 1 + return 1 + end + vim.fn.jobstart = function(...) + jobstart_calls = jobstart_calls + 1 + return 1 + end + + -- Minimal logger + server mocks + package.loaded["claudecode.logger"] = { + debug = function() end, + warn = function() end, + error = function() end, + info = function() end, + setup = function() end, + } + package.loaded["claudecode.server.init"] = { state = { port = 12345 } } + + -- Ensure fresh terminal module load + package.loaded["claudecode.terminal"] = nil + package.loaded["claudecode.terminal.none"] = nil + package.loaded["claudecode.terminal.native"] = nil + package.loaded["claudecode.terminal.snacks"] = nil + + terminal = require("claudecode.terminal") + terminal.setup({ provider = "none" }, nil, {}) + end) + + it("does not invoke any terminal APIs", function() + -- Exercise all public actions + terminal.open({}, "--help") + terminal.simple_toggle({}, "--resume") + terminal.focus_toggle({}, "--continue") + terminal.ensure_visible({}, nil) + terminal.toggle_open_no_focus({}, nil) + terminal.close() + + -- Assert no terminal processes/windows were spawned + assert.are.equal(0, termopen_calls) + assert.are.equal(0, jobstart_calls) + end) + + it("returns nil for active buffer", function() + local bufnr = terminal.get_active_terminal_bufnr() + assert.is_nil(bufnr) + end) +end)