Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
74 changes: 74 additions & 0 deletions lua/claudecode/terminal/none.lua
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions lua/claudecode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/terminal/none_provider_spec.lua
Original file line number Diff line number Diff line change
@@ -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)