From f1771dc6a78e0da324253ed44cc19cde6b0e53d8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Jun 2026 15:02:44 +0200 Subject: [PATCH 1/4] fix(diff): scope closeAllDiffTabs to own diffs; never reuse &diff windows (#277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closeAllDiffTabs previously closed every window with `&diff` set and force-deleted any buffer named like `*.diff`/`diff://`/`fugitive://`, without checking that claudecode created them. The Claude CLI invokes this tool at the start of every user turn while an IDE is connected, so an open diffview.nvim, fugitive, or native `:diffsplit` review was wiped out on essentially every prompt — only the diffview file panel (not a `&diff` window) survived, matching the reported symptom. Scope the tool to claudecode's own tracked-diff registry only (drop the window sweep and the buffer force-delete). This mirrors the official VS Code extension, which closes only the diff tabs it labelled "[Claude Code] ..." — an ownership check, not an all-diffs sweep. Also guard the second defect: `find_main_editor_window` (in both open_file.lua and diff.lua) did not exclude windows in diff mode, so openFile/openDiff could `:edit` into one half of a foreign diff, which clears that window's `diff` option and drops its partner out of the diff group. Skip `&diff` windows in both finders, the open_file fallback, and the buffer-reuse branch of the diff setup code. Adds a reproduction fixture (fixtures/issue-277, diffview.nvim) and an agent-tty + MCP harness (scripts/repro_issue_277.sh); both defects flip from 3/3 reproduced to 0/3 with this change. Rewrites the two unit tests that pinned the old destructive sweep and adds &diff coverage to the window-finder specs. Change-Id: If023bdcbcd1bcd10b6169282112ce75e09e17c56 Co-Authored-By: Claude Opus 4.8 Signed-off-by: Thomas Kosiewski --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- fixtures/issue-277/README.md | 49 ++++ fixtures/issue-277/init.lua | 136 ++++++++++ lua/claudecode/diff.lua | 12 +- lua/claudecode/tools/close_all_diff_tabs.lua | 85 +----- lua/claudecode/tools/open_file.lua | 15 +- scripts/repro_issue_277.sh | 256 ++++++++++++++++++ tests/mocks/vim.lua | 21 ++ .../diff_find_main_editor_window_spec.lua | 31 ++- tests/unit/tools/close_all_diff_tabs_spec.lua | 22 +- tests/unit/tools/open_file_spec.lua | 63 +++++ 12 files changed, 605 insertions(+), 88 deletions(-) create mode 100644 fixtures/issue-277/README.md create mode 100644 fixtures/issue-277/init.lua create mode 100755 scripts/repro_issue_277.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index b44cd6c9..40bbad95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Bug Fixes +- `closeAllDiffTabs` no longer destroys diffs it does not own. It previously closed every window with `&diff` set and force-deleted any buffer named like `*.diff`/`diff://`/`fugitive://`, so an open diffview.nvim, fugitive, or native `:diffsplit` review was wiped out — and because the Claude CLI calls this tool at the start of every turn, it happened on essentially every prompt. The tool is now scoped to claudecode's own tracked diffs (matching the official VS Code extension, which only closes the tabs it labelled). Relatedly, `openFile`/`openDiff` no longer reuse a window that is in diff mode, which previously `:edit`-ed over one side of an unrelated diff and broke its layout. ([#277](https://github.com/coder/claudecode.nvim/issues/277)) - The Claude terminal now adds the loopback hosts (`localhost`, `127.0.0.1`, `::1`) to `no_proxy`/`NO_PROXY`, so a configured `http_proxy`/`all_proxy` no longer tunnels Claude's `ws://127.0.0.1` IDE connection and causes queued @ mentions to time out. Existing `no_proxy` exclusions are preserved. ([#70](https://github.com/coder/claudecode.nvim/issues/70)) - `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)) - Rejecting a Claude diff with `:q` (or `:close` / `c` / closing the tab) now resolves it as rejected, matching the documented behavior. The proposed buffer is a scratch buffer that `:q` only hides, so the existing `BufDelete`/`BufUnload`/`BufWipeout` autocmds never fired; a `WinClosed` autocmd now handles window-close rejection. ([#238](https://github.com/coder/claudecode.nvim/issues/238)) diff --git a/CLAUDE.md b/CLAUDE.md index e43f13c5..1872d4c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ The WebSocket server implements secure authentication using: - `checkDocumentDirty` - Checks if document has unsaved changes - `saveDocument` - Saves document with detailed success/failure reporting - `getWorkspaceFolders` - Gets workspace folder information -- `closeAllDiffTabs` - Closes all diff-related tabs and windows +- `closeAllDiffTabs` - Closes the diffs claudecode itself opened (its tracked diff registry only). It never touches diffs created by other tools such as diffview.nvim, fugitive, or native `:diffsplit`, and `openFile`/`openDiff` never reuse a window that is currently in diff mode (issue #277). - `getDiagnostics` - Gets language diagnostics (errors, warnings) from the editor **Internal Tools** (not exposed via MCP): diff --git a/fixtures/issue-277/README.md b/fixtures/issue-277/README.md new file mode 100644 index 00000000..b93c84b2 --- /dev/null +++ b/fixtures/issue-277/README.md @@ -0,0 +1,49 @@ +# Fixture: issue #277 — closeAllDiffTabs destroys foreign diffs + +Reproduction environment for +[#277 "[BUG] closeAllDiffTabs closes all diff-mode windows, destroying unrelated diffs (diffview.nvim)"](https://github.com/coder/claudecode.nvim/issues/277). + +Two defects: + +1. `tools/close_all_diff_tabs.lua` closes **every** window with `&diff` set (and + force-deletes `%.diff$` / `diff://` / `fugitive://` buffers) with no check + that claudecode created them. The Claude CLI invokes `closeAllDiffTabs` at + the **start of each user turn** when an IDE is connected (verified against + CLI 2.1.175), so any diffview.nvim / fugitive / native vimdiff layout that is + open when you submit a prompt gets destroyed. +2. `find_main_editor_window()` (in `tools/open_file.lua` and `diff.lua`) does + not exclude `&diff` windows, so `openFile`/`openDiff` can `:edit` into one + half of a foreign diff, corrupting it (the new buffer joins the diff). + +## Scripted reproduction + +```bash +scripts/repro_issue_277.sh +``` + +Drives a real Neovim TUI under agent-tty, opens diffview/native diffs, then +sends the same MCP `tools/call` requests the Claude CLI sends. Prints +`REPRODUCED:`/`NOT REPRODUCED:` per phase; exits 0 when all three defects +reproduce. + +## Manual reproduction + +```bash +source fixtures/nvim-aliases.sh && vv issue-277 # cwd must be a git repo with changes +``` + +1. `:DiffviewOpen` — side-by-side diff appears (2 windows with `&diff` + file panel). +2. Connect Claude (`:ClaudeCode`, or any client with the lock-file token). +3. Submit any prompt (or send `closeAllDiffTabs` by hand). +4. Both diff windows close; only the Diffview panel survives. `aw` + shows the diff-window count; `:ReproState` dumps per-window state. + +`:ReproNativeDiff ` opens a plugin-free native vimdiff for the same +experiment (no diffview involved). + +## Notes + +- diffview.nvim is cloned into `stdpath("data")/diffview.nvim` + (`~/.local/share/issue-277/`) on first start. +- The fixture exposes `v:lua.Repro277State()`, `v:lua.Repro277DiffWinCount()` + and `v:lua.Repro277Server()` for `--remote-expr` scripting. diff --git a/fixtures/issue-277/init.lua b/fixtures/issue-277/init.lua new file mode 100644 index 00000000..82a5f6da --- /dev/null +++ b/fixtures/issue-277/init.lua @@ -0,0 +1,136 @@ +-- Fixture for issue #277: +-- "[BUG] closeAllDiffTabs closes all diff-mode windows, destroying unrelated +-- diffs (diffview.nvim)" +-- https://github.com/coder/claudecode.nvim/issues/277 +-- +-- Two defects under test: +-- 1. tools/close_all_diff_tabs.lua closes EVERY `&diff` window (no ownership +-- check), so diffview.nvim / fugitive / native `:diffthis` layouts are +-- destroyed when the Claude CLI fires closeAllDiffTabs (it does so at the +-- start of a user turn whenever an IDE is connected). +-- 2. find_main_editor_window (tools/open_file.lua and diff.lua) does not +-- exclude `&diff` windows, so openFile/openDiff target a diffview window +-- and :edit into it, corrupting the diff layout. +-- +-- The fixture pulls in diffview.nvim (cloned on first run) and exposes a +-- window-state probe for scripted verification: +-- nvim --server --remote-expr 'v:lua.Repro277State()' +-- +-- Usage (from repo root): +-- source fixtures/nvim-aliases.sh && vv issue-277 +-- or scripted: +-- scripts/repro_issue_277.sh +-- +-- Manual repro: open a file in a git repo with uncommitted changes, +-- :DiffviewOpen, connect claude (--ide), submit any prompt -> the side-by-side +-- diff windows close, only the Diffview file panel survives. + +local config_dir = vim.fn.stdpath("config") +local repo_root = vim.fn.fnamemodify(config_dir, ":h:h") +vim.opt.rtp:prepend(repo_root) + +vim.g.mapleader = " " +vim.g.maplocalleader = "\\" + +-- --------------------------------------------------------------------------- +-- diffview.nvim (cloned into stdpath("data") on first run; no plugin manager) +-- --------------------------------------------------------------------------- +local diffview_dir = vim.fn.stdpath("data") .. "/diffview.nvim" +if vim.fn.isdirectory(diffview_dir) == 0 then + vim.notify("issue-277 fixture: cloning diffview.nvim ...") + local out = vim.fn.system({ + "git", + "clone", + "--depth=1", + "https://github.com/sindrets/diffview.nvim", + diffview_dir, + }) + assert(vim.v.shell_error == 0, "failed to clone diffview.nvim: " .. out) +end +vim.opt.rtp:prepend(diffview_dir) + +local ok_dv, diffview = pcall(require, "diffview") +assert(ok_dv, "Failed to load diffview.nvim: " .. tostring(diffview)) +diffview.setup({}) + +-- --------------------------------------------------------------------------- +-- claudecode.nvim (dev version from this repo) +-- --------------------------------------------------------------------------- +local ok, claudecode = pcall(require, "claudecode") +assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode)) + +claudecode.setup({ + auto_start = true, -- server + lock file immediately, so scripts can connect + -- "warn", not "debug": multi-line debug echoes trip nvim's hit-enter prompt, + -- which blocks --remote-expr probes in the scripted repro. + log_level = "warn", + terminal = { + provider = "native", + auto_close = false, + }, +}) + +vim.o.showtabline = 2 +vim.o.laststatus = 2 + +-- --------------------------------------------------------------------------- +-- Window-state probe (for --remote-expr / on-screen verification) +-- --------------------------------------------------------------------------- + +---Compact state of every window across all tabpages. +---@return string JSON: [{win,tab,name,buftype,filetype,diff}...] +function _G.Repro277State() + local out = {} + for _, win in ipairs(vim.api.nvim_list_wins()) do + local buf = vim.api.nvim_win_get_buf(win) + local name = vim.api.nvim_buf_get_name(buf) + out[#out + 1] = { + win = win, + tab = vim.api.nvim_tabpage_get_number(vim.api.nvim_win_get_tabpage(win)), + name = vim.fn.fnamemodify(name, ":t") ~= "" and vim.fn.fnamemodify(name, ":~:.") or "[No Name]", + buftype = vim.bo[buf].buftype, + filetype = vim.bo[buf].filetype, + diff = vim.wo[win].diff, + } + end + return vim.json.encode(out) +end + +---WebSocket endpoint of the running claudecode server ("port token", or "" if +---not started yet). Lets scripts connect without scanning ~/.claude/ide. +---@return string +function _G.Repro277Server() + local cc = require("claudecode") + if cc.state.port and cc.state.auth_token then + return cc.state.port .. " " .. cc.state.auth_token + end + return "" +end + +---Count of windows currently in diff mode (quick assertion helper). +---@return integer +function _G.Repro277DiffWinCount() + local n = 0 + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.wo[win].diff then + n = n + 1 + end + end + return n +end + +vim.api.nvim_create_user_command("ReproState", function() + vim.notify(_G.Repro277State()) +end, { desc = "Show issue-277 window state" }) + +-- Native (plugin-free) diff variant of the same bug: two `:diffsplit` windows. +vim.api.nvim_create_user_command("ReproNativeDiff", function(cmd_opts) + local args = vim.split(cmd_opts.args, "%s+") + assert(#args == 2, "usage: :ReproNativeDiff ") + vim.cmd("edit " .. vim.fn.fnameescape(args[1])) + vim.cmd("vertical diffsplit " .. vim.fn.fnameescape(args[2])) +end, { nargs = "+", complete = "file", desc = "Open a native vimdiff of two files" }) + +vim.keymap.set("n", "aw", function() + vim.notify(("diff windows: %d"):format(_G.Repro277DiffWinCount())) +end, { desc = "Show diff window count" }) diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index f4f81227..aaf38004 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -141,6 +141,13 @@ local function find_main_editor_window() is_suitable = false end + -- Skip windows already in diff mode -- a user vimdiff/diffview.nvim/fugitive + -- pane, or one of claudecode's own diff panes. Opening a file into one + -- clears its window-local 'diff' and destroys that diff layout (issue #277). + if is_suitable and vim.api.nvim_win_get_option(win, "diff") then + is_suitable = false + end + if is_suitable and ( @@ -1289,7 +1296,10 @@ function M._setup_blocking_diff(params, resolution_callback) if existing_buffer then for _, win in ipairs(vim.api.nvim_list_wins()) do - if vim.api.nvim_win_get_buf(win) == existing_buffer then + -- Don't reuse a window that is already in diff mode (e.g. the old + -- file shown inside the user's diffview/vimdiff): diffing into it + -- would join and corrupt that diff (issue #277). + if vim.api.nvim_win_get_buf(win) == existing_buffer and not vim.api.nvim_win_get_option(win, "diff") then target_window = win break end diff --git a/lua/claudecode/tools/close_all_diff_tabs.lua b/lua/claudecode/tools/close_all_diff_tabs.lua index 00afd536..bc890cbc 100644 --- a/lua/claudecode/tools/close_all_diff_tabs.lua +++ b/lua/claudecode/tools/close_all_diff_tabs.lua @@ -13,79 +13,20 @@ local schema = { ---Closes all diff tabs/windows in the editor. ---@return table response MCP-compliant response with content array indicating number of closed tabs. local function handler(params) - local closed_count = 0 - - -- Tear down tracked diffs first (resolving their pending coroutines); the - -- window/buffer scan below would otherwise leak that diff state (issue #248). + -- Tear down only the diffs this plugin created, resolving their pending + -- coroutines (issue #248). claudecode.diff.active_diffs is the authoritative + -- record of every diff claudecode opened, so it is the complete and safe scope + -- for this tool. + -- + -- We deliberately do NOT scan for "any window with &diff set" or buffers named + -- like *.diff / diff:// / fugitive://: those belong to the user's own diff + -- tools (diffview.nvim, fugitive, native :diffsplit). Claude's CLI invokes + -- closeAllDiffTabs at the START OF EVERY TURN while an IDE is connected, so an + -- unscoped sweep silently destroyed unrelated diffs on each prompt (issue + -- #277). This also matches the official VS Code extension, which closes only + -- tabs it labelled "[Claude Code] ..." -- i.e. its own diffs. local diff = require("claudecode.diff") - closed_count = closed_count + diff.close_all_diffs("closeAllDiffTabs tool") - - -- Get all windows (catches any untracked diff windows, e.g. fugitive) - local windows = vim.api.nvim_list_wins() - local windows_to_close = {} -- Use set to avoid duplicates - - for _, win in ipairs(windows) do - local buf = vim.api.nvim_win_get_buf(win) - local buftype = vim.api.nvim_buf_get_option(buf, "buftype") - local diff_mode = vim.api.nvim_win_get_option(win, "diff") - local should_close = false - - -- Check if this is a diff window - if diff_mode then - should_close = true - end - - -- Also check for diff-related buffer names or types - local buf_name = vim.api.nvim_buf_get_name(buf) - if buf_name:match("%.diff$") or buf_name:match("diff://") then - should_close = true - end - - -- Check for special diff buffer types - if buftype == "nofile" and buf_name:match("^fugitive://") then - should_close = true - end - - -- Add to close set only once (prevents duplicates) - if should_close then - windows_to_close[win] = true - end - end - - -- Close the identified diff windows - for win, _ in pairs(windows_to_close) do - if vim.api.nvim_win_is_valid(win) then - local success = pcall(vim.api.nvim_win_close, win, false) - if success then - closed_count = closed_count + 1 - end - end - end - - -- Also check for buffers that might be diff-related but not currently in windows - local buffers = vim.api.nvim_list_bufs() - for _, buf in ipairs(buffers) do - if vim.api.nvim_buf_is_loaded(buf) then - local buf_name = vim.api.nvim_buf_get_name(buf) - local buftype = vim.api.nvim_buf_get_option(buf, "buftype") - - -- Check for diff-related buffers - if - buf_name:match("%.diff$") - or buf_name:match("diff://") - or (buftype == "nofile" and buf_name:match("^fugitive://")) - then - -- Delete the buffer if it's not in any window - local buf_windows = vim.fn.win_findbuf(buf) - if #buf_windows == 0 then - local success = pcall(vim.api.nvim_buf_delete, buf, { force = true }) - if success then - closed_count = closed_count + 1 - end - end - end - end - end + local closed_count = diff.close_all_diffs("closeAllDiffTabs tool") -- Return MCP-compliant format matching VS Code extension return { diff --git a/lua/claudecode/tools/open_file.lua b/lua/claudecode/tools/open_file.lua index 53c274a4..d8b6ca8f 100644 --- a/lua/claudecode/tools/open_file.lua +++ b/lua/claudecode/tools/open_file.lua @@ -72,6 +72,13 @@ local function find_main_editor_window() is_suitable = false end + -- Skip windows that are part of a diff (vimdiff, diffview.nvim, fugitive, or + -- claudecode's own diff): :edit-ing into one clears its window-local 'diff' + -- and destroys the user's diff layout (issue #277). + if is_suitable and vim.api.nvim_win_get_option(win, "diff") then + is_suitable = false + end + -- Skip known sidebar filetypes if is_suitable @@ -145,11 +152,13 @@ local function handler(params) vim.cmd("wincmd t") -- Go to top-left vim.cmd("wincmd l") -- Move right (to middle if layout is left|middle|right) - -- If we're still in a special window, create a new split - local buf = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) + -- If we're still in a special window (or a diff window — issue #277), create + -- a new split so we don't :edit over a terminal, sidebar, or someone's diff. + local cur_win = vim.api.nvim_get_current_win() + local buf = vim.api.nvim_win_get_buf(cur_win) local buftype = vim.api.nvim_buf_get_option(buf, "buftype") - if buftype == "terminal" or buftype == "nofile" then + if buftype == "terminal" or buftype == "nofile" or vim.api.nvim_win_get_option(cur_win, "diff") then vim.cmd("vsplit") end diff --git a/scripts/repro_issue_277.sh b/scripts/repro_issue_277.sh new file mode 100755 index 00000000..3c6d166b --- /dev/null +++ b/scripts/repro_issue_277.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# +# Reproduce issue #277: "[BUG] closeAllDiffTabs closes all diff-mode windows, +# destroying unrelated diffs (diffview.nvim)" +# https://github.com/coder/claudecode.nvim/issues/277 +# +# Drives a real Neovim TUI (fixtures/issue-277, diffview.nvim + dev claudecode) +# inside agent-tty, then acts as the Claude CLI by sending MCP `tools/call` +# requests straight to the plugin's WebSocket server (the real CLI fires +# closeAllDiffTabs at the start of a user turn whenever an IDE is connected). +# +# Phases (each asserts the BUG reproduces, i.e. PASS == bug present): +# 1 :DiffviewOpen, then closeAllDiffTabs -> both side-by-side diff +# windows are closed, only the Diffview file panel survives ("blank review") +# 2 native `:vertical diffsplit`, then openFile -> the file is :edit-ed INTO a +# diff-mode window (find_main_editor_window does not exclude &diff windows) +# and joins the user's diff +# 3 native diff, then closeAllDiffTabs -> user's vimdiff windows are +# closed (no plugins involved at all) +# +# Requirements: nvim, agent-tty, websocat, jq, git, perl. No `claude` login is +# needed; the script speaks the MCP protocol itself. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/issue277.XXXXXX")" +AGENT_HOME="$WORK/att-home" +NVIM_SOCK="$WORK/nvim.sock" +DEMO_REPO="$WORK/demo-repo" +ARTIFACTS="${ISSUE277_ARTIFACTS:-$WORK/artifacts}" +DIFFVIEW_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/issue-277/diffview.nvim" + +mkdir -p "$AGENT_HOME" "$ARTIFACTS" + +for bin in nvim agent-tty jq git perl; do + command -v "$bin" >/dev/null 2>&1 || { + echo "MISSING dependency: $bin" >&2 + exit 3 + } +done + +# A `websocat` mise shim can exist but have no active version, so probe that the +# resolved command actually runs before trusting it. +WEBSOCAT=websocat +"$WEBSOCAT" --version >/dev/null 2>&1 || + WEBSOCAT="$(find "${MISE_DATA_DIR:-$HOME/.local/share/mise}/installs/websocat" -maxdepth 2 -name websocat -type f 2>/dev/null | head -1)" +if [ -z "$WEBSOCAT" ] || ! "$WEBSOCAT" --version >/dev/null 2>&1; then + echo "MISSING dependency: websocat (no working binary found)" >&2 + exit 3 +fi + +SESSION_ID="" +# shellcheck disable=SC2329 # invoked via `trap cleanup EXIT` +cleanup() { + [ -n "$SESSION_ID" ] && agent-tty --home "$AGENT_HOME" destroy "$SESSION_ID" --json >/dev/null 2>&1 + rm -rf "$WORK" +} +trap cleanup EXIT + +att() { agent-tty --home "$AGENT_HOME" "$@"; } +# Bounded --remote-expr: a modal hit-enter prompt in nvim would otherwise block +# the RPC (and this script) forever. +rexpr() { perl -e 'alarm 10; exec @ARGV' nvim --server "$NVIM_SOCK" --remote-expr "$1" 2>/dev/null; } +# Clear a hit-enter prompt ONLY if the RPC is actually blocked. A blind Enter +# would land in diffview's file panel ( = reopen the diff!) and undo the +# very state we are asserting on. +ensure_responsive() { + [ "$(rexpr '1+1')" = "2" ] && return 0 + att send-keys "$SESSION_ID" Enter --json >/dev/null 2>&1 + perl -e 'select(undef,undef,undef,0.5)' + [ "$(rexpr '1+1')" = "2" ] +} +fail() { + echo "SETUP FAILURE: $*" >&2 + exit 2 +} + +# mcp_call : one-shot MCP tools/call as the Claude +# client. The server does not gate tools/call on `initialize`, and these tools +# respond immediately, so a short-lived socket is enough. macOS has no +# `timeout`; hold stdin open briefly and bound the whole client with alarm. +mcp_call() { + local req + req=$(jq -nc --arg name "$1" --argjson args "$2" \ + '{jsonrpc:"2.0",id:1,method:"tools/call",params:{name:$name,arguments:$args}}') + { + printf '%s\n' "$req" + perl -e 'select(undef,undef,undef,2)' + } | + perl -e 'alarm 15; exec @ARGV' "$WEBSOCAT" -t "ws://127.0.0.1:$PORT" \ + --header "x-claude-code-ide-authorization: $TOKEN" 2>/dev/null | + jq -c 'select(.id == 1)' # drop interleaved broadcasts (selection_changed) + ensure_responsive +} + +# --------------------------------------------------------------------------- +# Setup: demo git repo with uncommitted changes + diffview.nvim clone +# --------------------------------------------------------------------------- +mkdir -p "$DEMO_REPO" +( + cd "$DEMO_REPO" || exit 1 + git init -q + git config user.email repro@example.com + git config user.name repro + for i in $(seq 1 12); do echo "local line_$i = $i"; done >a.lua + for i in $(seq 1 12); do echo "local other_$i = $i"; done >b.lua + echo "# demo repo for issue #277" >README.md + git add . && git commit -qm initial + # uncommitted change so :DiffviewOpen has something to show + printf 'local line_2 = 2000 -- CHANGED\n' >tmp && sed '2d' a.lua >>tmp && mv tmp a.lua +) || fail "could not build demo repo" + +if [ ! -d "$DIFFVIEW_DIR" ]; then + echo "[setup] cloning diffview.nvim -> $DIFFVIEW_DIR" + git clone -q --depth=1 https://github.com/sindrets/diffview.nvim "$DIFFVIEW_DIR" || + fail "could not clone diffview.nvim" +fi + +# --------------------------------------------------------------------------- +# Launch Neovim (issue-277 fixture) inside agent-tty +# --------------------------------------------------------------------------- +SESSION_ID=$(att create --json --cols 200 --rows 50 --cwd "$DEMO_REPO" \ + --name issue277 \ + --env NVIM_APPNAME=issue-277 \ + --env XDG_CONFIG_HOME="$REPO_ROOT/fixtures" \ + --env _ZO_DOCTOR=0 \ + -- nvim --listen "$NVIM_SOCK" | jq -r '.result.sessionId') +[ -n "$SESSION_ID" ] && [ "$SESSION_ID" != null ] || fail "agent-tty create failed" + +for _ in $(seq 1 60); do + [ -e "$NVIM_SOCK" ] && [ "$(rexpr '1+1')" = "2" ] && break + perl -e 'select(undef,undef,undef,0.25)' +done +[ "$(rexpr '1+1')" = "2" ] || fail "nvim RPC socket never came up" + +SERVER="" +for _ in $(seq 1 60); do + SERVER=$(rexpr 'v:lua.Repro277Server()') + [ -n "$SERVER" ] && break + perl -e 'select(undef,undef,undef,0.25)' +done +[ -n "$SERVER" ] || fail "claudecode server never started (auto_start)" +PORT=${SERVER%% *} +TOKEN=${SERVER##* } +echo "[setup] nvim up; claudecode ws on port $PORT" + +snap() { att snapshot "$SESSION_ID" --format text --json | jq -r '.result.text' >"$ARTIFACTS/$1.txt"; } + +PASS=0 +FAIL=0 +verdict() { #