diff --git a/CHANGELOG.md b/CHANGELOG.md index 71acef3b..91ddc750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,12 @@ ### Features +- `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)) ### Bug Fixes +- `focus_after_send = true` no longer fails silently with `terminal.provider = "none"`/`"external"`: those providers run Claude outside Neovim, so focus cannot move there. A one-time warning is now emitted at setup pointing to the new `User ClaudeCodeSendComplete` autocmd, which you can hook to focus your own terminal. (`focus_after_send` still only auto-focuses the in-editor providers.) ([#228](https://github.com/coder/claudecode.nvim/issues/228)) - Diffs opened via `openDiff` no longer linger forever when they are resolved outside this Neovim or their Claude session goes away. Pending diffs are now automatically closed when the client that opened them disconnects or the integration is stopped, and `closeAllDiffTabs` now also resolves/cleans the diff module's tracked state instead of only closing windows. ([#248](https://github.com/coder/claudecode.nvim/issues/248)) - Show diffs when the Claude Code terminal is the only window (no other splits). Previously `openDiff` failed with "No suitable editor window found"; now a split is created to host the diff, matching the behavior of the `openFile` tool. ([#231](https://github.com/coder/claudecode.nvim/issues/231)) - Work around a Neovim core bug (< 0.12.2) that fragmented large bracketed pastes into the terminal across `vim.paste` phases, making Cmd+V appear to truncate content. Added a scoped, version-gated `vim.paste` shim controlled by `terminal.fix_streamed_paste` (`"auto"` by default; no-op on Neovim >= 0.12.2). ([#161](https://github.com/coder/claudecode.nvim/issues/161)) diff --git a/CLAUDE.md b/CLAUDE.md index 833b75bd..e1845a86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,12 @@ opts = { - `plugin/claudecode.lua` - Plugin loader with version checks - `tests/` - Comprehensive test suite with unit, component, and integration tests +### Autocmd Events + +The plugin emits `User` autocmds (not config fields) that integrations can hook: + +- **`ClaudeCodeSendComplete`** - Fired in `M.send_at_mention` (init.lua) once per file, synchronously, when a send is accepted on the connected branch (acceptance-time, not delivery; not fired on the queued/disconnected path). `data = { file_path, start_line, end_line, context }` — `file_path` is the formatted path Claude received, lines are 0-indexed and may be nil. Primary use: focus an external Claude session (`provider = "none"`/`"external"`) where `focus_after_send` is inert. Emitted via the guarded, pcall-wrapped `fire_send_complete` helper (no-op when `vim.api.nvim_exec_autocmds` is absent, e.g. minimal test stubs). See `lua/claudecode/types.lua` `ClaudeCodeSendCompleteData` and README "Events". + ## MCP Protocol Compliance ### Protocol Implementation Status diff --git a/README.md b/README.md index 0253aec0..157ee1bd 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,43 @@ You can edit Claude's suggestions before accepting them. If a diff is resolved outside this Neovim (for example via Claude remote control on another device) the diff windows would otherwise stay open. They are now closed automatically when the Claude session that opened them disconnects. If you resolve diffs remotely while the session is still connected, run `:ClaudeCodeCloseAllDiffs` to clear the leftover pending proposals — it leaves any diff you have already accepted (`:w`) but whose file has not been written yet untouched, so your saved edits are never discarded. +## Events + +The plugin fires `User` autocmds you can hook with `nvim_create_autocmd`. + +### `ClaudeCodeSendComplete` + +Fired once per file, synchronously, when a send (`:ClaudeCodeSend`, `:ClaudeCodeAdd`, tree add, etc.) is **accepted while a Claude client is connected**. This is the recommended way to focus a Claude session that runs **outside** Neovim (`provider = "none"`/`"external"`), where `focus_after_send` cannot help. + +The autocmd `data` carries: + +| field | type | notes | +| ------------ | -------------- | ------------------------------------------------------------------------------------ | +| `file_path` | `string` | The formatted/cwd-relative path Claude received | +| `start_line` | `integer\|nil` | **0-indexed** (Claude convention, not 1-indexed editor lines); `nil` for whole files | +| `end_line` | `integer\|nil` | 0-indexed; `nil` for whole-file/directory sends | +| `context` | `string\|nil` | Internal trigger tag (e.g. `"ClaudeCodeSend"`); best-effort, may change | + +Notes and caveats: + +- Fires at **acceptance** time, not delivery — sending is debounced, so a later transport failure is logged, not reported here. +- Fires **per file**: sending a multi-file selection from a file-explorer buffer (`:ClaudeCodeSend` / `:ClaudeCodeTreeAdd`) fires it once per file. `:ClaudeCodeAdd` sends a single path and fires once. Keep handlers idempotent. +- Fires only when Claude is **already connected** at send time; a send that queues while Claude is launching is delivered later without firing this event. + +Example — focus a tmux pane after sending (supply your own pane target): + +```lua +vim.api.nvim_create_autocmd("User", { + pattern = "ClaudeCodeSendComplete", + callback = function(ev) + -- ev.data.file_path / ev.data.start_line / ev.data.end_line / ev.data.context + if vim.env.TMUX then + vim.fn.system({ "tmux", "select-pane", "-t", "{last}" }) -- replace target as needed + end + end, +}) +``` + ## How It Works This plugin creates a WebSocket server that Claude Code CLI connects to, implementing the same protocol as the official VS Code extension. When you launch Claude, it automatically detects Neovim and gains full access to your editor. @@ -253,7 +290,10 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). -- For native binary: use output from 'which claude' -- Send/Focus Behavior - -- When true, successful sends will focus the Claude terminal if already connected + -- When true, successful sends focus the in-editor Claude terminal if already + -- connected. NOTE: this only works for in-editor providers (snacks/native); + -- it has no effect with provider = "none"/"external" (Claude runs outside + -- Neovim). For those, hook the `User ClaudeCodeSendComplete` event (see Events). focus_after_send = false, -- Selection Tracking @@ -521,6 +561,7 @@ 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. +- `focus_after_send` has no effect here (there is no in-editor terminal to focus); enabling it logs a one-time warning at startup. To focus your external session after a send, hook the [`User ClaudeCodeSendComplete`](#claudecodesendcomplete) event. ### External Terminal Provider diff --git a/fixtures/issue-228/README.md b/fixtures/issue-228/README.md new file mode 100644 index 00000000..7f6c09e1 --- /dev/null +++ b/fixtures/issue-228/README.md @@ -0,0 +1,76 @@ +# Issue #228 — `focus_after_send` with `provider = "none"` / `"external"` + +> Source: https://github.com/coder/claudecode.nvim/issues/228 + +## Background + +`focus_after_send = true` focuses the **in-editor** Claude terminal after a send. It +routes through `terminal.open()`, which dispatches to the configured provider. The +`none` provider (`lua/claudecode/terminal/none.lua`) is a no-op by design, and the +`external` provider cannot move focus to an already-running external terminal. So for +those providers `focus_after_send` could never do anything — and it did so **silently**, +with no warning and no documentation of the limitation. + +## The fix (this change set) + +`focus_after_send` still cannot focus a Claude session running outside Neovim (it +can't — Claude isn't in a Neovim window). Instead: + +- **(c)** A one-time warning is emitted at `setup()` when `focus_after_send = true` and + `terminal.provider` is `"none"` or `"external"`, pointing users at the hook below. +- **(b)** A `User ClaudeCodeSendComplete` autocmd fires on every connected send, + carrying `data = { file_path, start_line, end_line, context }`, so external-terminal + users can run their own focus logic: + + ```lua + vim.api.nvim_create_autocmd("User", { + pattern = "ClaudeCodeSendComplete", + callback = function() + if vim.env.TMUX then + vim.fn.system({ "tmux", "select-pane", "-t", "{last}" }) -- use your own target + end + end, + }) + ``` + +## Gate (deterministic, headless, no Claude CLI required) + +```sh +# from the repo root +bash fixtures/issue-228/run.sh +# or: nvim -u NONE -l fixtures/issue-228/repro.lua +``` + +Expected output ends with: + +``` +PASS issue #228 fix verified: warning fires for none/external, ClaudeCodeSendComplete fires on send. +``` + +The harness uses the **real** plugin and the **real** `none` provider, runs four +provider/flag scenarios plus a real `User ClaudeCodeSendComplete` autocmd, and asserts: + +| provider | focus_after_send | #228 warning | focus effect | event fires | +| -------------- | ---------------- | ------------ | --------------- | ----------- | +| `none` | `true` | **1** | none (no-op) | yes | +| `none` | `false` | 0 | none (no-op) | yes | +| custom (table) | `true` | 0 | **focus fires** | yes | +| custom (table) | `false` | 0 | show, no focus | yes | + +The `none` rows confirm the limitation is unchanged (no terminal is ever created), but +it is no longer silent (a warning fires) and the `ClaudeCodeSendComplete` event lets the +user focus their own terminal. The custom-provider rows confirm focusable providers are +unaffected and never warn. + +## Live confirmation (agent-tty / TUI) + +`live.lua` is a minimal fixture (provider `none`, connection stubbed) that exercises the +real `:ClaudeCodeSend` path in a running Neovim and hooks the new event: + +```sh +nvim -u fixtures/issue-228/live.lua fixtures/issue-228/sample.txt +# then visually select lines and run :'<,'>ClaudeCodeSend (the real path), or +# run :Issue228Probe (before/after report) +# -> a "ClaudeCodeSendComplete fired" message appears; :messages also shows the +# one-time setup warning. focus_after_send itself stays inert for "none". +``` diff --git a/fixtures/issue-228/live.lua b/fixtures/issue-228/live.lua new file mode 100644 index 00000000..114132e0 --- /dev/null +++ b/fixtures/issue-228/live.lua @@ -0,0 +1,104 @@ +-- Live (TUI) fixture for issue #228 — run a real Neovim and watch the FIX: +-- * provider="none" + focus_after_send=true emits a one-time warning at setup, and +-- * a `User ClaudeCodeSendComplete` autocmd fires on every connected :ClaudeCodeSend, +-- which this fixture hooks to prove the event (the focus_after_send option itself +-- still cannot focus a Claude session running outside Neovim — that's expected). +-- The websocket connection is STUBBED so the real :ClaudeCodeSend path runs with no CLI. +-- +-- Usage (from repo root): +-- nvim -u fixtures/issue-228/live.lua fixtures/issue-228/sample.txt +-- +-- Then either: +-- * visually select a few lines and run :'<,'>ClaudeCodeSend (the real path), or +-- * run :Issue228Probe (deterministic before/after report) +-- Expect: focus stays on the sample buffer (focus_after_send is inert for "none"), and +-- a "ClaudeCodeSendComplete fired" message appears (the new hook). :messages also shows +-- the one-time setup warning. + +local script_path = debug.getinfo(1, "S").source:sub(2) +local repo_root = vim.fn.fnamemodify(script_path, ":h:h:h") +vim.opt.runtimepath:prepend(repo_root) + +vim.g.mapleader = " " +vim.o.number = true +vim.o.laststatus = 2 + +local claudecode = require("claudecode") +claudecode.setup({ + auto_start = false, + log_level = "warn", -- the #228 setup warning is WARN level + track_selection = true, -- required for the visual :ClaudeCodeSend path + focus_after_send = true, -- the option under test (inert for provider="none") + terminal = { provider = "none" }, -- triggers the #228 warning + makes focus a no-op +}) + +-- ---- Stub a connected Claude so send_at_mention takes the "connected" branch ---- +local server_init = require("claudecode.server.init") +server_init.get_status = function() + return { running = true, client_count = 1 } +end +claudecode.state.server = { + _fake = true, + stop = function() + return true + end, + broadcast = function() + return true + end, +} +claudecode._broadcast_at_mention = function(file_path, s, e) + vim.notify(("(stub) broadcast @%s:%s-%s"):format(file_path, tostring(s), tostring(e)), vim.log.levels.INFO) + return true, nil, { file_path = file_path, start_line = s, end_line = e } +end + +-- start() normally enables selection tracking; we skipped it, so enable directly +-- so the real visual :ClaudeCodeSend path works against the stubbed server. +require("claudecode.selection").enable(claudecode.state.server, 50) + +-- ---- The #228 (b) hook: prove the event fires (this is what an external-terminal +-- ---- user would use to run e.g. `tmux select-pane`). ---- +vim.api.nvim_create_autocmd("User", { + group = vim.api.nvim_create_augroup("Issue228Demo", { clear = true }), + pattern = "ClaudeCodeSendComplete", + callback = function(ev) + local d = ev.data or {} + vim.notify( + ("ClaudeCodeSendComplete fired: file=%s lines=%s-%s"):format( + tostring(d.file_path), + tostring(d.start_line), + tostring(d.end_line) + ), + vim.log.levels.INFO + ) + end, +}) + +-- ---- Deterministic probe: report focus/window state before and after a send ---- +local function snapshot() + return { + win = vim.api.nvim_get_current_win(), + buf = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ":t"), + wins = vim.fn.winnr("$"), + } +end + +vim.api.nvim_create_user_command("Issue228Probe", function() + local before = snapshot() + local file = vim.api.nvim_buf_get_name(0) + claudecode.send_at_mention(file, 0, 2, "Issue228Probe") + local after = snapshot() + local moved = (before.win ~= after.win) or (before.wins ~= after.wins) + local lines = { + "issue #228 probe (provider=none, focus_after_send=true)", + (" before: win=%d buf=%s wins=%d"):format(before.win, before.buf, before.wins), + (" after : win=%d buf=%s wins=%d"):format(after.win, after.buf, after.wins), + (" focus moved by focus_after_send? %s (expected: NO)"):format(tostring(moved)), + " (a ClaudeCodeSendComplete message above proves the new hook fired)", + } + vim.notify(table.concat(lines, "\n"), moved and vim.log.levels.WARN or vim.log.levels.INFO) +end, { desc = "Issue #228: send and report focus + that the event fired" }) + +-- short, single-line banner (a long one trips the hit-enter prompt under automation) +vim.schedule(function() + vim.notify("issue228 fixture ready — run :Issue228Probe or :'<,'>ClaudeCodeSend", vim.log.levels.INFO) +end) diff --git a/fixtures/issue-228/repro.lua b/fixtures/issue-228/repro.lua new file mode 100644 index 00000000..00787864 --- /dev/null +++ b/fixtures/issue-228/repro.lua @@ -0,0 +1,280 @@ +-- Regression gate for issue #228: focus_after_send + provider="none"/"external". +-- +-- Run from the repo root: +-- nvim -u NONE -l fixtures/issue-228/repro.lua +-- (or: bash fixtures/issue-228/run.sh) +-- +-- History: focus_after_send used to fail SILENTLY under provider="none"/"external" +-- (the focus call routes to a no-op provider). The fix does NOT make focus work +-- for those providers (it can't — Claude runs outside Neovim); instead it: +-- (c) warns ONCE at setup when focus_after_send=true and provider is none/external, and +-- (b) fires a `User ClaudeCodeSendComplete` autocmd on every connected send so users +-- can run their own focus logic (e.g. tmux select-pane). +-- This script verifies that fixed behavior, against the REAL plugin + REAL providers. + +local script_path = debug.getinfo(1, "S").source:sub(2) +local repo_root = vim.fn.fnamemodify(script_path, ":h:h:h") +vim.opt.runtimepath:prepend(repo_root) + +local PASS = "PASS" +local FAIL = "FAIL" + +-- Wipe every claudecode.* module so each scenario starts from clean state. +local function reset_modules() + for name in pairs(package.loaded) do + if name:match("^claudecode") then + package.loaded[name] = nil + end + end +end + +-- Custom provider TABLE (a fully-supported provider kind) that records whether a +-- focus/visibility action actually took effect. +local function recording_provider(record) + return { + setup = function() end, + open = function() + record.open_effect = true + end, + ensure_visible = function() + record.ensure_visible_effect = true + end, + close = function() end, + simple_toggle = function() end, + focus_toggle = function() end, + get_active_bufnr = function() + return nil + end, + is_available = function() + return true + end, + } +end + +---@param opts table { name, provider, focus_after_send } +local function run_scenario(opts) + reset_modules() + + local claudecode = require("claudecode") + local logger = require("claudecode.logger") + + -- Capture the #228 setup warning (overridden BEFORE setup so we see it fire). + local warnings = {} + logger.warn = function(_, msg) + table.insert(warnings, tostring(msg)) + end + + claudecode.setup({ + auto_start = false, + log_level = "warn", + track_selection = false, + focus_after_send = opts.focus_after_send, + terminal = { provider = opts.provider }, + }) + + local terminal = require("claudecode.terminal") + + -- Instrument the REAL `none` provider to confirm focus_after_send still routes + -- to a no-op there (the underlying limitation is unchanged; only the UX is). + local calls = { open = 0, ensure_visible = 0 } + if opts.provider == "none" then + local none = require("claudecode.terminal.none") + local real_open, real_ensure = none.open, none.ensure_visible + none.open = function(...) + calls.open = calls.open + 1 + return real_open(...) + end + none.ensure_visible = function(...) + calls.ensure_visible = calls.ensure_visible + 1 + return real_ensure(...) + end + end + + -- Force the "connected" branch without a real websocket. + local server_init = require("claudecode.server.init") + server_init.get_status = function() + return { running = true, client_count = 1 } + end + claudecode.state.server = { _fake = true } + claudecode._broadcast_at_mention = function() + return true, nil + end + + claudecode.send_at_mention("/tmp/issue228.lua", 0, 5, "repro") + + -- Count only the #228 warning (by stable substring). + local focus_warnings = 0 + for _, w in ipairs(warnings) do + if w:find("does not focus a Claude session", 1, true) then + focus_warnings = focus_warnings + 1 + end + end + + return { + name = opts.name, + focus_warnings = focus_warnings, + warning_text = warnings[#warnings], + none_open_calls = calls.open, + none_ensure_calls = calls.ensure_visible, + active_bufnr = terminal.get_active_terminal_bufnr(), + record = opts.record, + } +end + +-- (b) Real end-to-end check that `User ClaudeCodeSendComplete` fires on a connected +-- send, carrying the payload. Uses a fresh augroup (clear=true) because reset_modules +-- does NOT clear Neovim's global autocmd registry. +local function check_event_fires() + reset_modules() + local claudecode = require("claudecode") + claudecode.setup({ + auto_start = false, + log_level = "warn", + track_selection = false, + focus_after_send = true, + terminal = { provider = "none" }, + }) + local server_init = require("claudecode.server.init") + server_init.get_status = function() + return { running = true, client_count = 1 } + end + claudecode.state.server = { _fake = true } + claudecode._broadcast_at_mention = function(fp, s, e) + return true, nil, { file_path = fp, start_line = s, end_line = e } + end + + local captured = { count = 0, data = nil } + local group = vim.api.nvim_create_augroup("Issue228EventProbe", { clear = true }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "ClaudeCodeSendComplete", + callback = function(ev) + captured.count = captured.count + 1 + captured.data = ev.data + end, + }) + + claudecode.send_at_mention("/tmp/issue228.lua", 0, 5, "repro") + + vim.api.nvim_clear_autocmds({ group = group }) + return captured +end + +-- Setup-only check of the (c) warning for a given provider. Used for "external" +-- (which the run_scenario path can't exercise headlessly, since its terminal.open +-- would try to spawn). A valid external_terminal_cmd avoids the fallback-to-native +-- warning so we observe only the #228 warning. +local function setup_warning_count(provider) + reset_modules() + local claudecode = require("claudecode") + local logger = require("claudecode.logger") + local warnings = {} + logger.warn = function(_, msg) + if tostring(msg):find("does not focus a Claude session", 1, true) then + table.insert(warnings, msg) + end + end + claudecode.setup({ + auto_start = false, + log_level = "warn", + track_selection = false, + focus_after_send = true, + terminal = { provider = provider, provider_opts = { external_terminal_cmd = "xterm -e %s" } }, + }) + return #warnings +end + +print("\n=== issue #228 fix verification: warning (c) + ClaudeCodeSendComplete event (b) ===\n") + +local rec_focus = {} +local rec_nofocus = {} + +local none_true = run_scenario({ name = "none / focus_after_send=true", provider = "none", focus_after_send = true }) +local none_false = run_scenario({ name = "none / focus_after_send=false", provider = "none", focus_after_send = false }) +local custom_true = run_scenario({ + name = "custom provider / focus_after_send=true", + provider = recording_provider(rec_focus), + focus_after_send = true, + record = rec_focus, +}) +local custom_false = run_scenario({ + name = "custom provider / focus_after_send=false", + provider = recording_provider(rec_nofocus), + focus_after_send = false, + record = rec_nofocus, +}) +local event = check_event_fires() +local external_warnings = setup_warning_count("external") + +for _, r in ipairs({ none_true, none_false, custom_true, custom_false }) do + print(("--- %s ---"):format(r.name)) + print((" #228 setup warnings: %d"):format(r.focus_warnings)) + if r.none_open_calls + r.none_ensure_calls > 0 then + print((" none.open() calls: %d"):format(r.none_open_calls)) + print((" none.ensure() calls: %d"):format(r.none_ensure_calls)) + end + if r.record then + print((" provider open effect: %s"):format(tostring(r.record.open_effect == true))) + print((" provider show effect: %s"):format(tostring(r.record.ensure_visible_effect == true))) + end + print((" active terminal bufnr: %s"):format(tostring(r.active_bufnr))) + print("") +end +print("--- ClaudeCodeSendComplete event ---") +print((" fired: %d time(s)"):format(event.count)) +print((" payload: %s"):format(vim.inspect(event.data):gsub("%s+", " "))) +print("") + +local checks = {} +local function check(desc, cond) + table.insert(checks, { desc = desc, ok = cond }) +end + +-- (c) The warning now fires for the inert combination, and points at the hook. +check("provider=none + focus_after_send=true emits exactly one #228 warning", none_true.focus_warnings == 1) +check( + "the warning names focus_after_send and points at ClaudeCodeSendComplete", + none_true.warning_text ~= nil + and none_true.warning_text:find("focus_after_send", 1, true) + and none_true.warning_text:find("ClaudeCodeSendComplete", 1, true) +) +check("provider=external + focus_after_send=true also warns", external_warnings == 1) +check("provider=none + focus_after_send=false stays silent", none_false.focus_warnings == 0) +-- The underlying limitation is unchanged: focus_after_send still cannot focus a +-- none terminal (no buffer is ever created); the fix is the warning + the event. +check( + "provider=none never creates a terminal (limitation unchanged)", + none_true.active_bufnr == nil and none_false.active_bufnr == nil +) +-- A focusable provider is unaffected: no warning, and focus_after_send still works. +check("custom (focusable) provider does NOT warn", custom_true.focus_warnings == 0 and custom_false.focus_warnings == 0) +check( + "focus_after_send=true triggers a real provider's open(); false uses ensure_visible()", + rec_focus.open_effect == true and rec_nofocus.ensure_visible_effect == true and rec_nofocus.open_effect ~= true +) +-- (b) The event fires exactly once per connected send, with the expected payload. +check("User ClaudeCodeSendComplete fired exactly once on a connected send", event.count == 1) +check( + "event payload carries file_path/start_line/end_line/context", + event.data ~= nil + and event.data.file_path == "/tmp/issue228.lua" + and event.data.start_line == 0 + and event.data.end_line == 5 + and event.data.context == "repro" +) + +print("=== verdict ===") +local all_ok = true +for _, c in ipairs(checks) do + print((" [%s] %s"):format(c.ok and PASS or FAIL, c.desc)) + all_ok = all_ok and c.ok +end + +print("") +if all_ok then + print(PASS .. " issue #228 fix verified: warning fires for none/external, ClaudeCodeSendComplete fires on send.") + vim.cmd("qa!") +else + print(FAIL .. " fix verification failed (behaviour regressed).") + vim.cmd("cq!") +end diff --git a/fixtures/issue-228/run.sh b/fixtures/issue-228/run.sh new file mode 100755 index 00000000..9618414a --- /dev/null +++ b/fixtures/issue-228/run.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# One-command reproduction for issue #228. +# Runs the headless harness against the plugin in this repo. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$here/../.." && pwd)" + +nvim_bin="${NVIM:-nvim}" + +cd "$repo_root" +exec "$nvim_bin" -u NONE -l fixtures/issue-228/repro.lua diff --git a/fixtures/issue-228/sample.txt b/fixtures/issue-228/sample.txt new file mode 100644 index 00000000..680d3bfa --- /dev/null +++ b/fixtures/issue-228/sample.txt @@ -0,0 +1,6 @@ +issue #228 sample buffer +------------------------ +line 1: select me +line 2: select me +line 3: select me +line 4: then run :'<,'>ClaudeCodeSend (or :Issue228Probe) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 550a4022..6934730f 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -273,6 +273,36 @@ function M._ensure_terminal_visible_if_connected() return true end +---Fire the `User ClaudeCodeSendComplete` autocmd so integrations can react after +---a send (e.g. focus an externally-managed Claude session). Fires once per file, +---synchronously on the same tick, right after the focus action. +---NOTE: the `data` field of nvim_exec_autocmds requires Neovim >= 0.8.0 (the +---plugin floor). Guarded so minimal vim stubs are a no-op, and pcall'd so a +---faulty user callback cannot break the send path or abort batch loops. +---`modeline = false` because this is a notification-only event: without it +---nvim_exec_autocmds defaults `modeline = true` and would re-process the current +---buffer's modeline on every send (re-applying options / surfacing unrelated +---modeline errors). +---@param file_path string Path Claude received (formatted/relative) +---@param start_line number|nil Start line (0-indexed for Claude), or nil +---@param end_line number|nil End line (0-indexed for Claude), or nil +---@param context string|nil Internal logging context tag +local function fire_send_complete(file_path, start_line, end_line, context) + if not (vim.api and vim.api.nvim_exec_autocmds) then + return + end + pcall(vim.api.nvim_exec_autocmds, "User", { + pattern = "ClaudeCodeSendComplete", + modeline = false, + data = { + file_path = file_path, + start_line = start_line, + end_line = end_line, + context = context, + }, + }) +end + ---Send @ mention to Claude Code, handling connection state automatically ---@param file_path string The file path to send ---@param start_line number|nil Start line (0-indexed for Claude) @@ -291,7 +321,7 @@ function M.send_at_mention(file_path, start_line, end_line, context) -- Check if Claude Code is connected if M.is_claude_connected() then -- Claude is connected, send immediately and ensure terminal is visible - local success, error_msg = M._broadcast_at_mention(file_path, start_line, end_line) + local success, error_msg, sent = M._broadcast_at_mention(file_path, start_line, end_line) if success then local terminal = require("claudecode.terminal") if M.state.config and M.state.config.focus_after_send then @@ -300,6 +330,13 @@ function M.send_at_mention(file_path, start_line, end_line, context) else terminal.ensure_visible() end + -- Fire ClaudeCodeSendComplete. NOTE: in production `success` means the + -- mention was ACCEPTED/queued, not yet delivered (delivery is debounced and + -- failures are logged, not returned), so this is an "accepted/dispatched" + -- signal. `sent` carries the formatted path/lines Claude received; fall back + -- to the raw args when a caller/test stubs _broadcast_at_mention. + sent = sent or { file_path = file_path, start_line = start_line, end_line = end_line } + fire_send_complete(sent.file_path, sent.start_line, sent.end_line, context) end return success, error_msg else @@ -316,6 +353,42 @@ function M.send_at_mention(file_path, start_line, end_line, context) end end +---Warn once (at setup) when focus_after_send is enabled but the effective provider +---runs Claude outside Neovim, where focus_after_send cannot take effect (#228). The +---"none" provider is a no-op, and "external" cannot move focus to an already-running +---external terminal -- but ONLY when it is actually usable: a misconfigured +---"external" (no valid external_terminal_cmd) falls back to the native provider, +---where focus_after_send works, so we must not warn there. Only the two built-in +---string providers are checked; a custom table provider that is itself a no-op for +---focus is the author's responsibility. +---@param config table|nil The resolved configuration +function M._maybe_warn_unfocusable_provider(config) + if not (config and config.focus_after_send == true) then + return + end + local terminal = config.terminal + local provider = terminal and terminal.provider + + local unfocusable = provider == "none" + if provider == "external" then + -- Mirror terminal.lua's has_external_cmd check: without a usable command, + -- "external" silently falls back to native (focusable), so do not warn. + local cmd = terminal.provider_opts and terminal.provider_opts.external_terminal_cmd + unfocusable = type(cmd) == "function" or (type(cmd) == "string" and cmd ~= "" and cmd:find("%%s") ~= nil) + end + + if unfocusable then + logger.warn( + "config", + string.format( + "focus_after_send=true does not focus a Claude session running outside Neovim " + .. "(terminal.provider=%q). Use a `User ClaudeCodeSendComplete` autocmd to focus it yourself.", + provider + ) + ) + end +end + ---Set up the plugin with user configuration ---@param opts PartialClaudeCodeConfig|nil Optional configuration table to override defaults. ---@return table module The plugin module @@ -362,6 +435,10 @@ function M.setup(opts) logger.error("init", "Failed to load claudecode.terminal module for setup.") end + -- Surface the #228 footgun: focus_after_send is inert for providers that run + -- Claude outside Neovim. Warns once here at setup (not per-send). + M._maybe_warn_unfocusable_provider(M.state.config) + local diff = require("claudecode.diff") diff.setup(M.state.config) @@ -1181,6 +1258,15 @@ function M._broadcast_at_mention(file_path, start_line, end_line) lineEnd = end_line, } + -- What Claude actually received, surfaced to send_at_mention so the + -- ClaudeCodeSendComplete event payload reflects the formatted path and the + -- directory-adjusted line numbers rather than the raw caller arguments. + local sent = { + file_path = formatted_path, + start_line = start_line, + end_line = end_line, + } + -- For tests or when explicitly configured, broadcast immediately without queuing if (M.state.config and M.state.config.disable_broadcast_debouncing) @@ -1188,7 +1274,7 @@ function M._broadcast_at_mention(file_path, start_line, end_line) then local broadcast_success = M.state.server.broadcast("at_mentioned", params) if broadcast_success then - return true, nil + return true, nil, sent else local error_msg = "Failed to broadcast " .. (is_directory and "directory" or "file") .. " " .. formatted_path logger.error("command", error_msg) @@ -1201,7 +1287,7 @@ function M._broadcast_at_mention(file_path, start_line, end_line) -- Always return success since we're queuing the message -- The actual broadcast result will be logged in the queue processing - return true, nil + return true, nil, sent end function M._add_paths_to_claude(file_paths, options) diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index c57a4f1b..c90ed8f7 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -60,6 +60,17 @@ ---@field end_line number? Optional end line (0-indexed for Claude compatibility) ---@field timestamp number Creation timestamp from vim.loop.now() for expiry tracking +-- Payload delivered as `data` to a `User ClaudeCodeSendComplete` autocmd. Fired +-- once per file, synchronously, when a send is accepted while Claude is connected. +-- Lines are 0-indexed (Claude convention, NOT 1-indexed editor lines) and may be +-- nil for whole-file/directory sends. file_path is the formatted/relative path +-- Claude received. See README "Events". +---@class ClaudeCodeSendCompleteData +---@field file_path string Formatted path Claude received (relative to cwd when applicable) +---@field start_line number? Start line, 0-indexed for Claude, or nil +---@field end_line number? End line, 0-indexed for Claude, or nil +---@field context string? Internal logging tag (e.g. "ClaudeCodeSend"); best-effort, may change + -- Terminal provider interface ---@class ClaudeCodeTerminalProvider ---@field setup fun(config: ClaudeCodeTerminalConfig) diff --git a/tests/mocks/vim.lua b/tests/mocks/vim.lua index 2c1e5b9e..aba3d17c 100644 --- a/tests/mocks/vim.lua +++ b/tests/mocks/vim.lua @@ -113,6 +113,11 @@ local vim = { end end, + nvim_exec_autocmds = function(event, opts) + vim._exec_autocmds = vim._exec_autocmds or {} + table.insert(vim._exec_autocmds, { event = event, opts = opts }) + end, + nvim_get_current_buf = function() return 1 end, @@ -999,6 +1004,7 @@ vim._mock = { vim._next_winid = 1000 vim._commands = {} vim._autocmds = {} + vim._exec_autocmds = {} vim._vars = {} vim._options = {} vim._last_command = nil diff --git a/tests/unit/focus_after_send_provider_warning_spec.lua b/tests/unit/focus_after_send_provider_warning_spec.lua new file mode 100644 index 00000000..a7c21801 --- /dev/null +++ b/tests/unit/focus_after_send_provider_warning_spec.lua @@ -0,0 +1,115 @@ +require("tests.busted_setup") +require("tests.mocks.vim") + +-- #228: focus_after_send is inert for providers that run Claude outside Neovim. +-- M._maybe_warn_unfocusable_provider surfaces that footgun once at setup. +describe("focus_after_send unfocusable-provider warning (#228)", function() + local claudecode + local warnings + + before_each(function() + package.loaded["claudecode"] = nil + package.loaded["claudecode.logger"] = nil + claudecode = require("claudecode") + -- init.lua's `logger` local references this same table, so replacing .warn + -- here is observed by _maybe_warn_unfocusable_provider. + local logger = require("claudecode.logger") + warnings = {} + logger.warn = function(_, msg) + table.insert(warnings, tostring(msg)) + end + end) + + after_each(function() + package.loaded["claudecode"] = nil + package.loaded["claudecode.logger"] = nil + end) + + -- Count only the #228 warning, by stable substring (other warnings may exist). + local function focus_warnings() + local n = 0 + for _, w in ipairs(warnings) do + if w:find("does not focus a Claude session", 1, true) then + n = n + 1 + end + end + return n + end + + it("warns for provider=none + focus_after_send=true", function() + claudecode._maybe_warn_unfocusable_provider({ focus_after_send = true, terminal = { provider = "none" } }) + assert.is_equal(1, focus_warnings()) + assert.is_truthy(warnings[#warnings]:find("ClaudeCodeSendComplete", 1, true)) + end) + + it("warns for provider=external with a usable external_terminal_cmd (string)", function() + claudecode._maybe_warn_unfocusable_provider({ + focus_after_send = true, + terminal = { provider = "external", provider_opts = { external_terminal_cmd = "xterm -e %s" } }, + }) + assert.is_equal(1, focus_warnings()) + assert.is_truthy(warnings[#warnings]:find("ClaudeCodeSendComplete", 1, true)) + end) + + it("warns for provider=external with a function external_terminal_cmd", function() + claudecode._maybe_warn_unfocusable_provider({ + focus_after_send = true, + terminal = { + provider = "external", + provider_opts = { + external_terminal_cmd = function() + return { "xterm" } + end, + }, + }, + }) + assert.is_equal(1, focus_warnings()) + end) + + -- Codex P3: a misconfigured "external" (no usable command) falls back to the + -- native provider, where focus_after_send DOES work — so it must not warn. + it("does NOT warn for provider=external without a usable command (falls back to native)", function() + claudecode._maybe_warn_unfocusable_provider({ focus_after_send = true, terminal = { provider = "external" } }) + assert.is_equal(0, focus_warnings()) + + claudecode._maybe_warn_unfocusable_provider({ + focus_after_send = true, + terminal = { provider = "external", provider_opts = { external_terminal_cmd = "no-placeholder" } }, + }) + assert.is_equal(0, focus_warnings()) + end) + + it("does not warn for provider=native", function() + claudecode._maybe_warn_unfocusable_provider({ focus_after_send = true, terminal = { provider = "native" } }) + assert.is_equal(0, focus_warnings()) + end) + + it("does not warn for provider=auto (resolves to a focusable provider)", function() + claudecode._maybe_warn_unfocusable_provider({ focus_after_send = true, terminal = { provider = "auto" } }) + assert.is_equal(0, focus_warnings()) + end) + + it("does not warn when focus_after_send=false", function() + claudecode._maybe_warn_unfocusable_provider({ focus_after_send = false, terminal = { provider = "none" } }) + assert.is_equal(0, focus_warnings()) + end) + + it("does not warn for a custom table provider (author's responsibility)", function() + claudecode._maybe_warn_unfocusable_provider({ + focus_after_send = true, + terminal = { + provider = { + is_available = function() + return true + end, + }, + }, + }) + assert.is_equal(0, focus_warnings()) + end) + + it("does not warn when terminal config is absent", function() + claudecode._maybe_warn_unfocusable_provider({ focus_after_send = true }) + assert.is_equal(0, focus_warnings()) + end) +end) diff --git a/tests/unit/send_complete_event_spec.lua b/tests/unit/send_complete_event_spec.lua new file mode 100644 index 00000000..b1eefc38 --- /dev/null +++ b/tests/unit/send_complete_event_spec.lua @@ -0,0 +1,195 @@ +require("tests.busted_setup") +require("tests.mocks.vim") + +-- #228 (b): send_at_mention fires `User ClaudeCodeSendComplete` on a successful +-- connected send, carrying the formatted path/lines Claude received. It does NOT +-- fire when the broadcast was not successful (and, by design, not on the queued +-- disconnected path — that delivery is debounced and never re-enters send_at_mention). +describe("ClaudeCodeSendComplete event (#228)", function() + local saved_require + local claudecode + local mock_terminal + local saved_fn + + local function setup_mocks() + mock_terminal = { + setup = function() end, + open = spy.new(function() end), + ensure_visible = spy.new(function() end), + } + local mock_logger = { + setup = function() end, + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + } + local mock_config = { + apply = function() + return { + auto_start = false, + terminal_cmd = nil, + env = {}, + log_level = "info", + track_selection = false, + focus_after_send = false, + diff_opts = { + layout = "vertical", + open_in_new_tab = false, + keep_terminal_focus = false, + on_new_file_reject = "keep_empty", + }, + models = { { name = "Test", value = "test" } }, + } + end, + } + + saved_require = _G.require + _G.require = function(mod) + if mod == "claudecode.config" then + return mock_config + elseif mod == "claudecode.logger" then + return mock_logger + elseif mod == "claudecode.diff" then + return { setup = function() end } + elseif mod == "claudecode.terminal" then + return mock_terminal + elseif mod == "claudecode.server.init" then + return { + get_status = function() + return { running = true, client_count = 1 } + end, + } + else + return saved_require(mod) + end + end + + claudecode = require("claudecode") + claudecode.setup({}) + claudecode.state.server = { + broadcast = function() + return true + end, + } + end + + before_each(function() + _G.vim._exec_autocmds = {} + saved_fn = { getcwd = _G.vim.fn.getcwd, isdirectory = _G.vim.fn.isdirectory } + end) + + after_each(function() + if saved_fn then + _G.vim.fn.getcwd = saved_fn.getcwd + _G.vim.fn.isdirectory = saved_fn.isdirectory + end + if saved_require then + _G.require = saved_require + end + package.loaded["claudecode"] = nil + package.loaded["claudecode.config"] = nil + package.loaded["claudecode.logger"] = nil + package.loaded["claudecode.diff"] = nil + package.loaded["claudecode.terminal"] = nil + package.loaded["claudecode.server.init"] = nil + end) + + -- Search by pattern (never absolute index/count): mock state can carry entries + -- from earlier specs in the shared busted process; we reset in before_each but + -- assert on the LAST matching ClaudeCodeSendComplete event regardless. + local function last_send_complete() + local found + for _, e in ipairs(_G.vim._exec_autocmds or {}) do + if e.event == "User" and e.opts and e.opts.pattern == "ClaudeCodeSendComplete" then + found = e + end + end + return found + end + + it("fires with the formatted payload returned by _broadcast_at_mention", function() + setup_mocks() + claudecode._broadcast_at_mention = function() + return true, nil, { file_path = "src/foo.lua", start_line = 2, end_line = 4 } + end + + local ok = claudecode.send_at_mention("/abs/src/foo.lua", 2, 4, "ClaudeCodeSend") + assert.is_true(ok) + + local ev = last_send_complete() + assert.is_not_nil(ev) + assert.is_equal("src/foo.lua", ev.opts.data.file_path) + assert.is_equal(2, ev.opts.data.start_line) + assert.is_equal(4, ev.opts.data.end_line) + assert.is_equal("ClaudeCodeSend", ev.opts.data.context) + -- notification-only event must not re-process the buffer's modeline (#228 review) + assert.is_false(ev.opts.modeline) + end) + + it("falls back to the raw args when no payload is returned", function() + setup_mocks() + claudecode._broadcast_at_mention = function() + return true, nil + end + + claudecode.send_at_mention("/abs/bar.lua", nil, nil, "ctx") + + local ev = last_send_complete() + assert.is_not_nil(ev) + assert.is_equal("/abs/bar.lua", ev.opts.data.file_path) + assert.is_nil(ev.opts.data.start_line) + assert.is_nil(ev.opts.data.end_line) + assert.is_equal("ctx", ev.opts.data.context) + end) + + it("does not fire when the broadcast was not successful", function() + setup_mocks() + claudecode._broadcast_at_mention = function() + return false, "broadcast failed" + end + + claudecode.send_at_mention("/abs/baz.lua", 1, 2, "ClaudeCodeSend") + + assert.is_nil(last_send_complete()) + end) + + -- Drive the REAL _broadcast_at_mention (NOT stubbed) so the payload's formatted + -- path / directory-adjusted lines are actually exercised. This is what locks in + -- the headline behavior: the event reports what Claude received, not the raw args. + it("reports the cwd-relative formatted path (real _broadcast_at_mention)", function() + setup_mocks() + _G.vim.fn.getcwd = function() + return "/Users/test/project" + end + _G.vim.fn.isdirectory = function() + return 0 + end + + claudecode.send_at_mention("/Users/test/project/src/foo.lua", 2, 4, "ClaudeCodeSend") + + local ev = last_send_complete() + assert.is_not_nil(ev) + assert.is_equal("src/foo.lua", ev.opts.data.file_path) + assert.is_equal(2, ev.opts.data.start_line) + assert.is_equal(4, ev.opts.data.end_line) + end) + + it("nulls line numbers for a directory send (real _broadcast_at_mention)", function() + setup_mocks() + _G.vim.fn.getcwd = function() + return "/Users/test/project" + end + _G.vim.fn.isdirectory = function() + return 1 + end + + claudecode.send_at_mention("/Users/test/project/lua", 2, 4, "ClaudeCodeSend") + + local ev = last_send_complete() + assert.is_not_nil(ev) + assert.is_equal("lua/", ev.opts.data.file_path) + assert.is_nil(ev.opts.data.start_line) + assert.is_nil(ev.opts.data.end_line) + end) +end)