From 444fe1d85ee6a394aeaf404065f7c9808cb0c9a8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 9 Jun 2026 11:01:12 +0200 Subject: [PATCH] feat(terminal): add :ClaudeCodeSendText to send text to the Claude pane (#197) Add `require("claudecode.terminal").send_to_terminal(text, opts)` and a `:ClaudeCodeSendText {text}` command that write text into the running Claude terminal's job channel via `chansend`, as if typed at the prompt. A trailing carriage return submits by default; `:ClaudeCodeSendText!` (and `opts.submit = false`) insert without submitting. Multi-line text is sent as a single bracketed-paste block (line endings normalized) so interior newlines don't fire premature submits. Works with the in-editor `native`/`snacks` providers; `external`/`none` run Claude outside Neovim, so there is no pane to write to and the call warns and returns false. The send is gated on `nvim_buf_is_valid` and a resolvable job channel (`b:terminal_job_id` with a `bo.channel` fallback for recovered terminals), and the `chansend` write is honored (closed channel -> false). Verified live against the real Claude CLI (2.1.169): single-line insert+submit, bang insert-only, and multi-line single-submit. 22 new unit tests. Change-Id: I2f68c38c76cf5b07cf6da1ba4c3b553252c5c0fe Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CHANGELOG.md | 1 + README.md | 13 + lua/claudecode/init.lua | 16 ++ lua/claudecode/terminal.lua | 97 ++++++++ .../claudecode_send_text_command_spec.lua | 140 +++++++++++ tests/unit/terminal/send_text_spec.lua | 230 ++++++++++++++++++ 6 files changed, 497 insertions(+) create mode 100644 tests/unit/claudecode_send_text_command_spec.lua create mode 100644 tests/unit/terminal/send_text_spec.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 944cfd77..ecf9c665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 1574f183..0ca9485d 100644 --- a/README.md +++ b/README.md @@ -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 [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: diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index d71ab7a6..e9526b4d 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -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", diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 5ed370a6..7d4ef782 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -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. diff --git a/tests/unit/claudecode_send_text_command_spec.lua b/tests/unit/claudecode_send_text_command_spec.lua new file mode 100644 index 00000000..65c3b811 --- /dev/null +++ b/tests/unit/claudecode_send_text_command_spec.lua @@ -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) diff --git a/tests/unit/terminal/send_text_spec.lua b/tests/unit/terminal/send_text_spec.lua new file mode 100644 index 00000000..60e3a64a --- /dev/null +++ b/tests/unit/terminal/send_text_spec.lua @@ -0,0 +1,230 @@ +-- Tests for terminal.send_to_terminal (#197): send raw text to the Claude pane. +require("tests.busted_setup") +require("tests.mocks.vim") + +describe("terminal.send_to_terminal (#197)", function() + local terminal + local chansend_calls + local warnings + local open_calls + local active_bufnr + local BUF = 4242 + + -- A minimal valid custom table provider whose get_active_bufnr we control. + local function custom_provider() + return { + setup = function() end, + open = function() + open_calls = open_calls + 1 + end, + close = function() end, + simple_toggle = function() end, + focus_toggle = function() end, + get_active_bufnr = function() + return active_bufnr + end, + is_available = function() + return true + end, + } + end + + local function register_buffer(bufnr, b_vars, channel) + vim._buffers[bufnr] = { lines = {}, options = {}, b_vars = b_vars or {} } + vim.bo[bufnr] = { channel = channel } + end + + before_each(function() + _G.vim = require("tests.mocks.vim") + vim._buffers = {} + -- The mock has no vim.bo; provide a simple table-of-tables for the fallback. + vim.bo = {} + + vim.fn = vim.fn or {} + vim.fn.getcwd = function() + return "/mock/cwd" + end + vim.fn.expand = function(val) + return val + end + vim.fn.fnamemodify = function(path) + return path + end + + chansend_calls = {} + vim.fn.chansend = function(chan, data) + table.insert(chansend_calls, { chan = chan, data = data }) + return type(data) == "string" and #data or 0 + end + + warnings = {} + package.loaded["claudecode.logger"] = { + debug = function() end, + warn = function(_, msg) + table.insert(warnings, msg) + end, + error = function() end, + info = function() end, + setup = function() end, + } + package.loaded["claudecode.server.init"] = { state = { port = 12345 } } + + package.loaded["claudecode.terminal"] = nil + package.loaded["claudecode.terminal.none"] = nil + package.loaded["claudecode.terminal.native"] = nil + package.loaded["claudecode.terminal.snacks"] = nil + + open_calls = 0 + active_bufnr = nil + terminal = require("claudecode.terminal") + end) + + local function setup_with_buffer(b_vars, channel) + active_bufnr = BUF + register_buffer(BUF, b_vars, channel) + terminal.setup({ provider = custom_provider() }, nil, {}) + end + + local function warned(pattern) + for _, m in ipairs(warnings) do + if tostring(m):match(pattern) then + return true + end + end + return false + end + + it("sends single-line text with a trailing CR by default", function() + setup_with_buffer({ terminal_job_id = 42 }) + local ok = terminal.send_to_terminal("hello") + assert.is_true(ok) + assert.are.equal(1, #chansend_calls) + assert.are.equal(42, chansend_calls[1].chan) + assert.are.equal("hello\r", chansend_calls[1].data) + end) + + it("omits the CR when submit=false", function() + setup_with_buffer({ terminal_job_id = 42 }) + local ok = terminal.send_to_terminal("hello", { submit = false }) + assert.is_true(ok) + assert.are.equal("hello", chansend_calls[1].data) + end) + + it("wraps multi-line text in bracketed paste then a CR", function() + setup_with_buffer({ terminal_job_id = 42 }) + terminal.send_to_terminal("a\nb") + assert.are.equal("\27[200~a\nb\27[201~\r", chansend_calls[1].data) + end) + + it("wraps multi-line text in bracketed paste with no CR when submit=false", function() + setup_with_buffer({ terminal_job_id = 42 }) + terminal.send_to_terminal("a\nb", { submit = false }) + assert.are.equal("\27[200~a\nb\27[201~", chansend_calls[1].data) + end) + + it("normalizes CRLF to LF and wraps as a single bracketed block", function() + setup_with_buffer({ terminal_job_id = 42 }) + terminal.send_to_terminal("a\r\nb") + assert.are.equal("\27[200~a\nb\27[201~\r", chansend_calls[1].data) + end) + + it("normalizes a lone CR (which would otherwise submit prematurely)", function() + setup_with_buffer({ terminal_job_id = 42 }) + terminal.send_to_terminal("a\rb") + assert.are.equal("\27[200~a\nb\27[201~\r", chansend_calls[1].data) + end) + + it("returns false and sends nothing for an empty string", function() + setup_with_buffer({ terminal_job_id = 42 }) + local ok = terminal.send_to_terminal("") + assert.is_false(ok) + assert.are.equal(0, #chansend_calls) + end) + + it("returns false for non-string text", function() + setup_with_buffer({ terminal_job_id = 42 }) + assert.is_false(terminal.send_to_terminal(nil)) + assert.is_false(terminal.send_to_terminal(123)) + assert.are.equal(0, #chansend_calls) + end) + + it("falls back to bo.channel when terminal_job_id is absent", function() + setup_with_buffer({}, 7) -- no terminal_job_id, channel = 7 + local ok = terminal.send_to_terminal("hi") + assert.is_true(ok) + assert.are.equal(7, chansend_calls[1].chan) + end) + + it("falls back to bo.channel when terminal_job_id is 0 (recovered terminal)", function() + setup_with_buffer({ terminal_job_id = 0 }, 99) -- job id lost on recovery, channel intact + local ok = terminal.send_to_terminal("hi") + assert.is_true(ok) + assert.are.equal(99, chansend_calls[1].chan) + end) + + it("returns false when no channel can be resolved", function() + setup_with_buffer({}, nil) -- no job id, no channel + local ok = terminal.send_to_terminal("hi") + assert.is_false(ok) + assert.are.equal(0, #chansend_calls) + assert.is_true(warned("no terminal job channel")) + end) + + it("treats terminal_job_id 0 as invalid", function() + setup_with_buffer({ terminal_job_id = 0 }, nil) + local ok = terminal.send_to_terminal("hi") + assert.is_false(ok) + assert.are.equal(0, #chansend_calls) + end) + + it("returns false and warns when no terminal is running (native)", function() + active_bufnr = nil + terminal.setup({ provider = "native" }, nil, {}) + local ok = terminal.send_to_terminal("hi") + assert.is_false(ok) + assert.are.equal(0, #chansend_calls) + assert.is_true(warned("no Claude terminal is currently running")) + end) + + it("warns and sends nothing for provider=none", function() + terminal.setup({ provider = "none" }, nil, {}) + local ok = terminal.send_to_terminal("hi") + assert.is_false(ok) + assert.are.equal(0, #chansend_calls) + assert.is_true(warned("outside Neovim")) + assert.is_true(warned("none")) + end) + + it("warns and sends nothing for provider=external", function() + terminal.setup({ provider = "external", provider_opts = { external_terminal_cmd = "alacritty -e %s" } }, nil, {}) + local ok = terminal.send_to_terminal("hi") + assert.is_false(ok) + assert.are.equal(0, #chansend_calls) + assert.is_true(warned("external")) + end) + + it("treats a stale (invalid) bufnr as no terminal", function() + active_bufnr = BUF -- provider returns it, but the buffer is never registered + terminal.setup({ provider = custom_provider() }, nil, {}) + local ok = terminal.send_to_terminal("hi") + assert.is_false(ok) + assert.are.equal(0, #chansend_calls) + end) + + it("focuses the terminal after a successful send when focus=true", function() + setup_with_buffer({ terminal_job_id = 42 }) + open_calls = 0 + local ok = terminal.send_to_terminal("hi", { focus = true }) + assert.is_true(ok) + assert.are.equal(1, #chansend_calls) + assert.are.equal(1, open_calls) + end) + + it("does not focus when the send fails even if focus=true", function() + setup_with_buffer({}, nil) -- no channel -> failure + open_calls = 0 + local ok = terminal.send_to_terminal("hi", { focus = true }) + assert.is_false(ok) + assert.are.equal(0, open_calls) + end) +end)