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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- `User ClaudeCodeSendComplete` autocmd, fired once per file when a send is accepted while Claude is connected, with `data = { file_path, start_line, end_line, context }` (lines 0-indexed). Lets you run arbitrary post-send logic — in particular, focus a Claude session running outside Neovim (`provider = "none"`/`"external"`), e.g. via `tmux select-pane`, which `focus_after_send` cannot do. ([#228](https://github.com/coder/claudecode.nvim/issues/228))
- `:ClaudeCodeCloseAllDiffs` command to close pending Claude diffs at once (e.g. proposals orphaned by resolving them via Claude remote control). Diffs you have already accepted but whose file has not been written yet are left intact so saved edits are never discarded. ([#248](https://github.com/coder/claudecode.nvim/issues/248))
- `:ClaudeCodeSendText {text}` command (and `require("claudecode.terminal").send_to_terminal(text, opts)` function) to send arbitrary text to the open Claude terminal as if typed at the prompt, submitting it by default. `:ClaudeCodeSendText!` inserts the text without submitting. Handy for scripting and keymaps; multi-line text is sent via bracketed paste. Works with the in-editor `native`/`snacks` providers only — `external`/`none` run Claude outside Neovim, where there is no pane to write to. ([#197](https://github.com/coder/claudecode.nvim/issues/197))

### Bug Fixes

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,24 @@ Configure the plugin with the detected path:
- `:ClaudeCodeFocus` - Smart focus/toggle Claude terminal
- `:ClaudeCodeSelectModel` - Select Claude model and open terminal with optional arguments
- `:ClaudeCodeSend` - Send current visual selection to Claude
- `:ClaudeCodeSendText {text}` - Send text to the open Claude terminal and submit it (`!` to insert without submitting; `native`/`snacks` providers only)
- `:ClaudeCodeAdd <file-path> [start-line] [end-line]` - Add specific file to Claude context with optional line range
- `:ClaudeCodeDiffAccept` - Accept diff changes
- `:ClaudeCodeDiffDeny` - Reject diff changes
- `:ClaudeCodeCloseAllDiffs` - Close pending Claude diffs (leaves accepted/saved diffs intact)

## Sending text to the Claude terminal

`:ClaudeCodeSendText {text}` types `{text}` into the open Claude terminal and submits it — useful for scripting and keymaps. Use `:ClaudeCodeSendText!` to insert the text without submitting. The same is available programmatically:

```lua
local terminal = require("claudecode.terminal")
terminal.send_to_terminal("run the test suite") -- types + submits
terminal.send_to_terminal("draft prompt", { submit = false }) -- insert only
```

This writes directly to the terminal's job channel, so it only works with the in-editor providers (`native`/`snacks`). The `external`/`none` providers run Claude outside Neovim, where there is no pane to write to (a warning is logged).

## Working with Diffs

When Claude proposes changes, the plugin opens a native Neovim diff view:
Expand Down
16 changes: 16 additions & 0 deletions lua/claudecode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,22 @@ function M._create_commands()
end, {
desc = "Close the Claude Code terminal window",
})

vim.api.nvim_create_user_command("ClaudeCodeSendText", function(opts)
local text = opts.args
if not text or text == "" then
logger.warn("command", "ClaudeCodeSendText: no text provided")
return
end
-- Sends to the currently-open Claude pane; the primitive warns if none is
-- running or the provider runs Claude outside Neovim (external/none). Bang
-- (`:ClaudeCodeSendText!`) inserts the text without submitting it.
terminal.send_to_terminal(text, { submit = not opts.bang })
end, {
nargs = "+",
bang = true,
desc = "Send text to the open Claude Code terminal and submit it (! to insert without submitting; native/snacks providers only)",
})
else
logger.error(
"init",
Expand Down
97 changes: 97 additions & 0 deletions lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,103 @@ function M.get_active_terminal_bufnr()
return get_provider().get_active_bufnr()
end

---Sends raw text to the running Claude Code terminal's job channel, as if it were
---typed at the prompt. By default a trailing carriage return submits the line.
---
---Only works for the in-editor providers ("native"/"snacks"). The "external" and
---"none" providers run Claude outside Neovim and expose no buffer, so this warns and
---returns false. This function is synchronous and does NOT open the terminal: it
---requires one to already be running, otherwise it warns and returns false. The
---`:ClaudeCodeSendText` command is a thin wrapper around this.
---
---Multi-line text is wrapped in bracketed-paste markers (ESC[200~ ... ESC[201~) so
---embedded newlines arrive as one literal pasted block rather than several premature
---submits; the submit carriage return is sent after the closing marker so it still
---triggers submission. `chansend` writes straight to the PTY and bypasses `vim.paste`,
---so the `fix_streamed_paste` shim is irrelevant here.
---@param text string The text to send. Must be a non-empty string.
---@param opts { submit?: boolean, focus?: boolean }? `submit` (default true) appends a carriage return so Claude submits the line; `focus` (default false) focuses the terminal after a successful send.
---@return boolean success Whether the text was written to a terminal channel.
function M.send_to_terminal(text, opts)
local logger = require("claudecode.logger")

if type(text) ~= "string" or text == "" then
logger.warn("terminal", "send_to_terminal: no text provided")
return false
end

opts = opts or {}
local submit = opts.submit ~= false

local bufnr = M.get_active_terminal_bufnr()
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
local provider_name = type(defaults.provider) == "string" and defaults.provider or "custom"
if provider_name == "none" or provider_name == "external" then
logger.warn(
"terminal",
string.format(
"Cannot send text: terminal.provider=%q runs Claude outside Neovim, so there is no pane to "
.. "write to. Use the 'native' or 'snacks' provider to send text programmatically.",
provider_name
)
)
else
logger.warn("terminal", "Cannot send text: no Claude terminal is currently running.")
end
return false
end

-- termopen() sets b:terminal_job_id; bo.channel is the robust fallback that also
-- survives a recovered terminal whose module-level job id was lost (native.lua).
local chan = vim.b[bufnr] and vim.b[bufnr].terminal_job_id
if not chan or chan == 0 then
chan = vim.bo[bufnr].channel
end
if not chan or chan == 0 then
logger.warn("terminal", "Cannot send text: no terminal job channel for buffer " .. tostring(bufnr))
return false
end

-- Normalize line endings so the ONLY submit byte is the trailing CR added below.
-- A bare "\r" is Enter at Claude's prompt, so any interior CR (e.g. CRLF or old-Mac
-- text from a programmatic caller) would otherwise fire one or more premature submits
-- -- the exact failure mode the bracketed-paste wrapping exists to prevent.
local normalized = (text:gsub("\r\n", "\n"):gsub("\r", "\n"))

local payload = normalized
if string.find(normalized, "\n", 1, true) then
-- Multi-line: bracketed paste so the newlines arrive as one literal block.
payload = "\27[200~" .. normalized .. "\27[201~"
end
if submit then
payload = payload .. "\r"
end

-- chansend can reject (0 bytes) or error if the channel is closed -- e.g. a recovered
-- terminal whose process already exited but whose buffer is still valid. Honor that
-- instead of reporting a false success.
local ok_send, written = pcall(vim.fn.chansend, chan, payload)
if not ok_send or written == 0 then
logger.warn("terminal", "Cannot send text: the Claude terminal channel is closed (the process may have exited).")
return false
end
logger.debug(
"terminal",
string.format(
"send_to_terminal: wrote %d byte(s) to channel %s (submit=%s)",
#payload,
tostring(chan),
tostring(submit)
)
)

if opts.focus then
M.open()
end

return true
end

---Gets the managed terminal instance for testing purposes.
-- NOTE: This function is intended for use in tests to inspect internal state.
-- The underscore prefix indicates it's not part of the public API for regular use.
Expand Down
140 changes: 140 additions & 0 deletions tests/unit/claudecode_send_text_command_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
-- Tests for the :ClaudeCodeSendText command (#197).
require("tests.busted_setup")
require("tests.mocks.vim")

describe("ClaudeCodeSendText command", function()
local claudecode
local mock_logger
local mock_terminal
local saved_require = _G.require

local function setup_mocks()
mock_logger = {
setup = function() end,
debug = spy.new(function() end),
error = spy.new(function() end),
warn = spy.new(function() end),
info = spy.new(function() end),
}

mock_terminal = {
setup = function() end,
open = spy.new(function() end),
close = spy.new(function() end),
simple_toggle = spy.new(function() end),
focus_toggle = spy.new(function() end),
ensure_visible = spy.new(function() end),
get_active_terminal_bufnr = function()
return 1
end,
send_to_terminal = spy.new(function()
return true
end),
}

vim.fn.getcwd = function()
return "/current/dir"
end
vim.api.nvim_create_user_command = spy.new(function() end)
vim.notify = spy.new(function() end)

_G.require = function(mod)
if mod == "claudecode.logger" then
return mock_logger
elseif mod == "claudecode.config" then
return {
apply = function(opts)
return opts or {}
end,
}
elseif mod == "claudecode.diff" then
return { setup = function() end }
elseif mod == "claudecode.terminal" then
return mock_terminal
elseif mod == "claudecode.visual_commands" then
return {
create_visual_command_wrapper = function(normal_handler)
return normal_handler
end,
}
else
return saved_require(mod)
end
end
end

before_each(function()
setup_mocks()

package.loaded["claudecode"] = nil
package.loaded["claudecode.config"] = nil
package.loaded["claudecode.logger"] = nil
package.loaded["claudecode.diff"] = nil
package.loaded["claudecode.visual_commands"] = nil
package.loaded["claudecode.terminal"] = nil

claudecode = require("claudecode")
claudecode.state.server = {
broadcast = spy.new(function()
return true
end),
}
claudecode.state.port = 12345
end)

after_each(function()
_G.require = saved_require
package.loaded["claudecode"] = nil
end)

local function find_command(name)
for _, call in ipairs(vim.api.nvim_create_user_command.calls) do
if call.vals[1] == name then
return call.vals[2], call.vals[3]
end
end
end

it("registers ClaudeCodeSendText with nargs=+ and bang support", function()
claudecode.setup({ auto_start = false })

local handler, config = find_command("ClaudeCodeSendText")
assert.is_function(handler)
assert.is_equal("+", config.nargs)
assert.is_true(config.bang)
assert.is_string(config.desc)
end)

it("sends text and submits by default", function()
claudecode.setup({ auto_start = false })
local handler = find_command("ClaudeCodeSendText")

handler({ args = "run the tests", bang = false })

assert.spy(mock_terminal.send_to_terminal).was_called()
local call = mock_terminal.send_to_terminal.calls[1]
assert.is_equal("run the tests", call.vals[1])
assert.is_true(call.vals[2].submit)
end)

it("inserts without submitting when bang is used", function()
claudecode.setup({ auto_start = false })
local handler = find_command("ClaudeCodeSendText")

handler({ args = "draft text", bang = true })

local call = mock_terminal.send_to_terminal.calls[1]
assert.is_equal("draft text", call.vals[1])
assert.is_false(call.vals[2].submit)
end)

it("warns and does not send when no text is provided", function()
claudecode.setup({ auto_start = false })
local handler = find_command("ClaudeCodeSendText")

handler({ args = "", bang = false })

assert.spy(mock_logger.warn).was_called()
assert.spy(mock_terminal.send_to_terminal).was_not_called()
end)
end)
Loading
Loading