Skip to content

Commit 254203b

Browse files
ThomasK33claude
andauthored
feat(terminal): send text to the Claude pane (:ClaudeCodeSendText) (#197) (#272)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent d462006 commit 254203b

6 files changed

Lines changed: 497 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- `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))
88
- `: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))
9+
- `: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))
910

1011
### Bug Fixes
1112

README.md

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

235+
## Sending text to the Claude terminal
236+
237+
`: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:
238+
239+
```lua
240+
local terminal = require("claudecode.terminal")
241+
terminal.send_to_terminal("run the test suite") -- types + submits
242+
terminal.send_to_terminal("draft prompt", { submit = false }) -- insert only
243+
```
244+
245+
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).
246+
234247
## Working with Diffs
235248

236249
When Claude proposes changes, the plugin opens a native Neovim diff view:

lua/claudecode/init.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,22 @@ function M._create_commands()
10981098
end, {
10991099
desc = "Close the Claude Code terminal window",
11001100
})
1101+
1102+
vim.api.nvim_create_user_command("ClaudeCodeSendText", function(opts)
1103+
local text = opts.args
1104+
if not text or text == "" then
1105+
logger.warn("command", "ClaudeCodeSendText: no text provided")
1106+
return
1107+
end
1108+
-- Sends to the currently-open Claude pane; the primitive warns if none is
1109+
-- running or the provider runs Claude outside Neovim (external/none). Bang
1110+
-- (`:ClaudeCodeSendText!`) inserts the text without submitting it.
1111+
terminal.send_to_terminal(text, { submit = not opts.bang })
1112+
end, {
1113+
nargs = "+",
1114+
bang = true,
1115+
desc = "Send text to the open Claude Code terminal and submit it (! to insert without submitting; native/snacks providers only)",
1116+
})
11011117
else
11021118
logger.error(
11031119
"init",

lua/claudecode/terminal.lua

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,103 @@ function M.get_active_terminal_bufnr()
633633
return get_provider().get_active_bufnr()
634634
end
635635

636+
---Sends raw text to the running Claude Code terminal's job channel, as if it were
637+
---typed at the prompt. By default a trailing carriage return submits the line.
638+
---
639+
---Only works for the in-editor providers ("native"/"snacks"). The "external" and
640+
---"none" providers run Claude outside Neovim and expose no buffer, so this warns and
641+
---returns false. This function is synchronous and does NOT open the terminal: it
642+
---requires one to already be running, otherwise it warns and returns false. The
643+
---`:ClaudeCodeSendText` command is a thin wrapper around this.
644+
---
645+
---Multi-line text is wrapped in bracketed-paste markers (ESC[200~ ... ESC[201~) so
646+
---embedded newlines arrive as one literal pasted block rather than several premature
647+
---submits; the submit carriage return is sent after the closing marker so it still
648+
---triggers submission. `chansend` writes straight to the PTY and bypasses `vim.paste`,
649+
---so the `fix_streamed_paste` shim is irrelevant here.
650+
---@param text string The text to send. Must be a non-empty string.
651+
---@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.
652+
---@return boolean success Whether the text was written to a terminal channel.
653+
function M.send_to_terminal(text, opts)
654+
local logger = require("claudecode.logger")
655+
656+
if type(text) ~= "string" or text == "" then
657+
logger.warn("terminal", "send_to_terminal: no text provided")
658+
return false
659+
end
660+
661+
opts = opts or {}
662+
local submit = opts.submit ~= false
663+
664+
local bufnr = M.get_active_terminal_bufnr()
665+
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
666+
local provider_name = type(defaults.provider) == "string" and defaults.provider or "custom"
667+
if provider_name == "none" or provider_name == "external" then
668+
logger.warn(
669+
"terminal",
670+
string.format(
671+
"Cannot send text: terminal.provider=%q runs Claude outside Neovim, so there is no pane to "
672+
.. "write to. Use the 'native' or 'snacks' provider to send text programmatically.",
673+
provider_name
674+
)
675+
)
676+
else
677+
logger.warn("terminal", "Cannot send text: no Claude terminal is currently running.")
678+
end
679+
return false
680+
end
681+
682+
-- termopen() sets b:terminal_job_id; bo.channel is the robust fallback that also
683+
-- survives a recovered terminal whose module-level job id was lost (native.lua).
684+
local chan = vim.b[bufnr] and vim.b[bufnr].terminal_job_id
685+
if not chan or chan == 0 then
686+
chan = vim.bo[bufnr].channel
687+
end
688+
if not chan or chan == 0 then
689+
logger.warn("terminal", "Cannot send text: no terminal job channel for buffer " .. tostring(bufnr))
690+
return false
691+
end
692+
693+
-- Normalize line endings so the ONLY submit byte is the trailing CR added below.
694+
-- A bare "\r" is Enter at Claude's prompt, so any interior CR (e.g. CRLF or old-Mac
695+
-- text from a programmatic caller) would otherwise fire one or more premature submits
696+
-- -- the exact failure mode the bracketed-paste wrapping exists to prevent.
697+
local normalized = (text:gsub("\r\n", "\n"):gsub("\r", "\n"))
698+
699+
local payload = normalized
700+
if string.find(normalized, "\n", 1, true) then
701+
-- Multi-line: bracketed paste so the newlines arrive as one literal block.
702+
payload = "\27[200~" .. normalized .. "\27[201~"
703+
end
704+
if submit then
705+
payload = payload .. "\r"
706+
end
707+
708+
-- chansend can reject (0 bytes) or error if the channel is closed -- e.g. a recovered
709+
-- terminal whose process already exited but whose buffer is still valid. Honor that
710+
-- instead of reporting a false success.
711+
local ok_send, written = pcall(vim.fn.chansend, chan, payload)
712+
if not ok_send or written == 0 then
713+
logger.warn("terminal", "Cannot send text: the Claude terminal channel is closed (the process may have exited).")
714+
return false
715+
end
716+
logger.debug(
717+
"terminal",
718+
string.format(
719+
"send_to_terminal: wrote %d byte(s) to channel %s (submit=%s)",
720+
#payload,
721+
tostring(chan),
722+
tostring(submit)
723+
)
724+
)
725+
726+
if opts.focus then
727+
M.open()
728+
end
729+
730+
return true
731+
end
732+
636733
---Gets the managed terminal instance for testing purposes.
637734
-- NOTE: This function is intended for use in tests to inspect internal state.
638735
-- The underscore prefix indicates it's not part of the public API for regular use.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
-- Tests for the :ClaudeCodeSendText command (#197).
2+
require("tests.busted_setup")
3+
require("tests.mocks.vim")
4+
5+
describe("ClaudeCodeSendText command", function()
6+
local claudecode
7+
local mock_logger
8+
local mock_terminal
9+
local saved_require = _G.require
10+
11+
local function setup_mocks()
12+
mock_logger = {
13+
setup = function() end,
14+
debug = spy.new(function() end),
15+
error = spy.new(function() end),
16+
warn = spy.new(function() end),
17+
info = spy.new(function() end),
18+
}
19+
20+
mock_terminal = {
21+
setup = function() end,
22+
open = spy.new(function() end),
23+
close = spy.new(function() end),
24+
simple_toggle = spy.new(function() end),
25+
focus_toggle = spy.new(function() end),
26+
ensure_visible = spy.new(function() end),
27+
get_active_terminal_bufnr = function()
28+
return 1
29+
end,
30+
send_to_terminal = spy.new(function()
31+
return true
32+
end),
33+
}
34+
35+
vim.fn.getcwd = function()
36+
return "/current/dir"
37+
end
38+
vim.api.nvim_create_user_command = spy.new(function() end)
39+
vim.notify = spy.new(function() end)
40+
41+
_G.require = function(mod)
42+
if mod == "claudecode.logger" then
43+
return mock_logger
44+
elseif mod == "claudecode.config" then
45+
return {
46+
apply = function(opts)
47+
return opts or {}
48+
end,
49+
}
50+
elseif mod == "claudecode.diff" then
51+
return { setup = function() end }
52+
elseif mod == "claudecode.terminal" then
53+
return mock_terminal
54+
elseif mod == "claudecode.visual_commands" then
55+
return {
56+
create_visual_command_wrapper = function(normal_handler)
57+
return normal_handler
58+
end,
59+
}
60+
else
61+
return saved_require(mod)
62+
end
63+
end
64+
end
65+
66+
before_each(function()
67+
setup_mocks()
68+
69+
package.loaded["claudecode"] = nil
70+
package.loaded["claudecode.config"] = nil
71+
package.loaded["claudecode.logger"] = nil
72+
package.loaded["claudecode.diff"] = nil
73+
package.loaded["claudecode.visual_commands"] = nil
74+
package.loaded["claudecode.terminal"] = nil
75+
76+
claudecode = require("claudecode")
77+
claudecode.state.server = {
78+
broadcast = spy.new(function()
79+
return true
80+
end),
81+
}
82+
claudecode.state.port = 12345
83+
end)
84+
85+
after_each(function()
86+
_G.require = saved_require
87+
package.loaded["claudecode"] = nil
88+
end)
89+
90+
local function find_command(name)
91+
for _, call in ipairs(vim.api.nvim_create_user_command.calls) do
92+
if call.vals[1] == name then
93+
return call.vals[2], call.vals[3]
94+
end
95+
end
96+
end
97+
98+
it("registers ClaudeCodeSendText with nargs=+ and bang support", function()
99+
claudecode.setup({ auto_start = false })
100+
101+
local handler, config = find_command("ClaudeCodeSendText")
102+
assert.is_function(handler)
103+
assert.is_equal("+", config.nargs)
104+
assert.is_true(config.bang)
105+
assert.is_string(config.desc)
106+
end)
107+
108+
it("sends text and submits by default", function()
109+
claudecode.setup({ auto_start = false })
110+
local handler = find_command("ClaudeCodeSendText")
111+
112+
handler({ args = "run the tests", bang = false })
113+
114+
assert.spy(mock_terminal.send_to_terminal).was_called()
115+
local call = mock_terminal.send_to_terminal.calls[1]
116+
assert.is_equal("run the tests", call.vals[1])
117+
assert.is_true(call.vals[2].submit)
118+
end)
119+
120+
it("inserts without submitting when bang is used", function()
121+
claudecode.setup({ auto_start = false })
122+
local handler = find_command("ClaudeCodeSendText")
123+
124+
handler({ args = "draft text", bang = true })
125+
126+
local call = mock_terminal.send_to_terminal.calls[1]
127+
assert.is_equal("draft text", call.vals[1])
128+
assert.is_false(call.vals[2].submit)
129+
end)
130+
131+
it("warns and does not send when no text is provided", function()
132+
claudecode.setup({ auto_start = false })
133+
local handler = find_command("ClaudeCodeSendText")
134+
135+
handler({ args = "", bang = false })
136+
137+
assert.spy(mock_logger.warn).was_called()
138+
assert.spy(mock_terminal.send_to_terminal).was_not_called()
139+
end)
140+
end)

0 commit comments

Comments
 (0)