From 04c9125d11b9424a5aa56710d9f973804c6a4dbd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 9 Jun 2026 10:51:59 +0200 Subject: [PATCH 1/3] fix(terminal): stop Snacks climbing cursor on hide/show toggle (#240, #183) Hiding and re-showing the Claude panel with the Snacks provider left the terminal cursor one row above Claude's prompt, so typed text landed on the wrong line and the prompt box corrupted. Root cause: Snacks hides by closing the window and shows by recreating it via open_win(); that recreate shifts the cursor anchor Claude (Ink) re-renders against on the focus-in event Neovim sends on show. It is not a pty resize. Manage hide/show without destroying the window: - float: park via nvim_win_set_config({hide=true/false}) (needs nvim-0.10) - split: close on hide, recreate with vsplit + nvim_win_set_buf on show, like the native provider (which does not drift); set full height, re-apply Snacks' window-local options, and keep the buffer so the map survives Also monkeypatch the Snacks instance hide/show/toggle so user-wired Snacks keymaps (e.g. self:hide()) get the fix, and make is_terminal_visible() treat a config-hidden window as not visible so ensure_visible/diff/send re-show a parked float. Splits are fixed on all supported Neovim versions; the float fix requires nvim-0.10 (pre-0.10 floats keep prior behavior). Adds tests/unit/terminal/snacks_toggle_spec.lua covering the float/split hide-show paths, config-hidden visibility, E444-safe close, and the externally-closed-float-reopens-as-float case. Change-Id: I71c521935460fc9fec0eaa45823a2c91002b4d8d Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CHANGELOG.md | 1 + lua/claudecode/terminal.lua | 16 +- lua/claudecode/terminal/snacks.lua | 334 +++++++++++++++++---- tests/unit/terminal/snacks_toggle_spec.lua | 320 ++++++++++++++++++++ 4 files changed, 608 insertions(+), 63 deletions(-) create mode 100644 tests/unit/terminal/snacks_toggle_spec.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 944cfd77..0c6363d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Push quickly-made visual selections to Claude reliably. Selections made and released faster than the selection-tracking debounce were never broadcast, and any selection was wiped shortly after leaving visual mode when Claude runs in an external terminal (the `/ide` flow) — so single-line selections in particular often never reached Claude. Selections are now flushed synchronously on visual-mode exit (from the `'<`/`'>` marks) and persist until the cursor actually moves; a single-line linewise `V` made right after a charwise selection is also no longer mis-extracted to a single character. ([#246](https://github.com/coder/claudecode.nvim/issues/246)) - 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)) +- Fix the "climbing cursor" in the Snacks terminal: hiding and re-showing the Claude panel no longer leaves the cursor one row above the prompt (so typed text lands on the wrong line). The Snacks provider now hides/shows without destroying the window — floats are parked via `nvim_win_set_config({hide=...})` and splits are recreated like the native provider — which preserves the cursor anchor Claude re-renders against on focus-in. Splits are fixed on all supported Neovim versions; the float fix requires Neovim >= 0.10. ([#240](https://github.com/coder/claudecode.nvim/issues/240), [#183](https://github.com/coder/claudecode.nvim/issues/183)) - 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)) ## [0.3.0] - 2025-09-15 diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 5ed370a6..dae64ccf 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -283,7 +283,21 @@ local function is_terminal_visible(bufnr) end local bufinfo = vim.fn.getbufinfo(bufnr) - return bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 + if not (bufinfo and #bufinfo > 0) then + return false + end + -- A config-hidden window (e.g. a Snacks float parked via + -- nvim_win_set_config({hide=true}) to dodge the climbing-cursor bug #240/#183) + -- still lists the buffer but is not actually on screen; don't count it. + for _, win in ipairs(bufinfo[1].windows or {}) do + if vim.api.nvim_win_is_valid(win) then + local ok, cfg = pcall(vim.api.nvim_win_get_config, win) + if not (ok and cfg and cfg.hide == true) then + return true + end + end + end + return false end ---Builds a no_proxy value that is guaranteed to exclude the loopback hosts diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 3c798994..105d039c 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -76,6 +76,254 @@ local function build_opts(config, env_table, focus) } --[[@as snacks.terminal.Opts]] end +-- --------------------------------------------------------------------------- +-- Climbing-cursor workaround (#240 split / #183 float). +-- +-- Snacks hides a terminal by CLOSING its window (nvim_win_close) and re-shows it +-- by recreating the window in open_win(). That recreate leaves Claude's terminal +-- cursor anchor one row off; Claude (Ink) re-renders relative to the cursor on +-- the focus-in event Neovim sends when the window is shown, so the prompt climbs +-- one row per toggle. Neither a pty resize nor the focus event alone causes it -- +-- it is the destroy+recreate of the window. To avoid disturbing the anchor we +-- manage hide/show ourselves: +-- * floating window -> nvim_win_set_config({hide=true/false}) keeps the window +-- (and its grid/cursor) alive. Requires nvim-0.10 (the `hide` win-config field). +-- * split window -> cannot be config-hidden, so close on hide and recreate with +-- a plain vsplit + nvim_win_set_buf on show, exactly like the native provider +-- (which does not drift). Buffer-local state (the map) survives. +-- --------------------------------------------------------------------------- + +local function win_get_config(win) + if not (win and vim.api.nvim_win_is_valid(win)) then + return nil + end + local ok, cfg = pcall(vim.api.nvim_win_get_config, win) + if ok then + return cfg + end + return nil +end + +-- A real split window reports relative == "" regardless of the opts Snacks was +-- given; a float reports "editor"/"win"/"cursor". +local function win_is_floating(win) + local cfg = win_get_config(win) + return cfg ~= nil and cfg.relative ~= nil and cfg.relative ~= "" +end + +local function win_is_config_hidden(win) + local cfg = win_get_config(win) + return cfg ~= nil and cfg.hide == true +end + +local function supports_config_hide() + return vim.fn ~= nil and vim.fn.has ~= nil and vim.fn.has("nvim-0.10") == 1 +end + +local function start_insert_if_terminal(term) + if + term.buf + and vim.api.nvim_buf_is_valid(term.buf) + and vim.api.nvim_buf_get_option(term.buf, "buftype") == "terminal" + and term.win + and vim.api.nvim_win_is_valid(term.win) + then + vim.api.nvim_win_call(term.win, function() + vim.cmd("startinsert") + end) + end +end + +-- Visible == a live window that shows our buffer and is not config-hidden. +local function cc_is_visible(term) + local win = term and term.win + if not (win and vim.api.nvim_win_is_valid(win)) then + return false + end + if win_is_config_hidden(win) then + return false + end + -- Match Snacks' own valid() / the native provider: a window showing some other + -- buffer is not "our" terminal being visible. + return vim.api.nvim_win_get_buf(win) == term.buf +end + +local function set_backdrop_hidden(term, hidden) + if term.backdrop and term.backdrop.win and vim.api.nvim_win_is_valid(term.backdrop.win) then + pcall(vim.api.nvim_win_set_config, term.backdrop.win, { hide = hidden }) + end +end + +-- Re-apply the Snacks-managed window-local options/vars that are lost when a +-- split window is closed and recreated (minimal style turns off number/sign +-- column, sets winhighlight, etc.), so the re-shown split matches the original. +local function reapply_snacks_window_state(term, win) + if not (term.opts and vim.api.nvim_win_is_valid(win)) then + return + end + if snacks_available and Snacks and Snacks.util and Snacks.util.wo and term.opts.wo then + pcall(Snacks.util.wo, win, term.opts.wo) + end + for k, v in pairs(term.opts.w or {}) do + pcall(function() + vim.w[win][k] = v + end) + end + -- Stacking marker so a second Snacks split can equalize against this one. + pcall(function() + vim.w[win].snacks_win = { id = term.id, position = term.opts.position } + end) +end + +---Hide the terminal window without disturbing the cursor anchor. +---@param term table Snacks terminal instance +local function cc_hide(term) + if not cc_is_visible(term) then + return + end + local logger = require("claudecode.logger") + local win = term.win + if win_is_floating(win) then + if supports_config_hide() then + logger.debug("terminal", "Snacks hide: config-hiding float (hide=true)") + if term._cc then + term._cc.kind = "float" + end + vim.api.nvim_win_set_config(win, { hide = true }) + set_backdrop_hidden(term, true) + -- Neovim does not auto-leave a config-hidden window; step out of it. + if vim.api.nvim_get_current_win() == win then + pcall(vim.cmd, "wincmd p") + end + elseif term._cc and term._cc.orig_hide then + -- Pre-0.10 float: no config-hide available, fall back to Snacks (cursor + -- drift is not avoidable on these versions). Recreate as a float on show. + logger.debug("terminal", "Snacks hide: pre-0.10 float via Snacks (drift unavoidable)") + term._cc.kind = "float" + term._cc.orig_hide(term) + end + else + -- Split: close the window, keep the buffer + job alive. pcall so closing the + -- LAST window (E444) is a harmless no-op instead of a throw; only forget the + -- window on a successful close so state stays consistent. + logger.debug("terminal", "Snacks hide: closing split window") + if term._cc then + term._cc.kind = "split" + end + if pcall(vim.api.nvim_win_close, win, false) then + term.win = nil + end + end +end + +---Show the terminal window, recreating splits the native way. +---@param term table Snacks terminal instance +---@param focus boolean Whether to focus the terminal and enter insert mode +---@param config table Effective terminal config (split_side, split_width_percentage) +---@return boolean shown +local function cc_show(term, focus, config) + if not (term and term.buf and vim.api.nvim_buf_is_valid(term.buf)) then + return false + end + local logger = require("claudecode.logger") + local win = term.win + + -- Config-hidden float -> just un-hide it. + if win and vim.api.nvim_win_is_valid(win) and win_is_config_hidden(win) then + logger.debug("terminal", "Snacks show: un-hiding config-hidden float") + vim.api.nvim_win_set_config(win, { hide = false }) + set_backdrop_hidden(term, false) + if focus then + vim.api.nvim_set_current_win(win) + start_insert_if_terminal(term) + end + return true + end + + -- Already visible -> optionally focus. + if cc_is_visible(term) then + if focus then + vim.api.nvim_set_current_win(term.win) + start_insert_if_terminal(term) + end + return true + end + + -- Window is fully gone. A FLOAT (one we closed on pre-0.10, or that was closed + -- externally) must be recreated by Snacks so it stays a float -- recreating it + -- as a split would be wrong. Splits use the native vsplit path, which preserves + -- Claude's cursor anchor. + local want_float = (term._cc and term._cc.kind == "float") + or (config and config.snacks_win_opts and config.snacks_win_opts.position == "float") + if want_float and term._cc and term._cc.orig_show then + logger.debug("terminal", "Snacks show: re-creating float via Snacks") + term._cc.kind = nil + term._cc.orig_show(term) + if focus and term.win and vim.api.nvim_win_is_valid(term.win) then + vim.api.nvim_set_current_win(term.win) + start_insert_if_terminal(term) + end + return true + end + + logger.debug("terminal", "Snacks show: re-creating split (native vsplit)") + local original_win = vim.api.nvim_get_current_win() + local pct = (config and config.split_width_percentage) or 0.30 + local width = math.floor(vim.o.columns * pct) + local placement = ((config and config.split_side) == "left") and "topleft " or "botright " + vim.cmd(placement .. width .. "vsplit") + local new_win = vim.api.nvim_get_current_win() + -- Set term.win before nvim_win_set_buf so Snacks' fixbuf BufWinEnter autocmd + -- (if still registered) sees a valid window and does not self-delete. + term.win = new_win + vim.api.nvim_win_set_buf(new_win, term.buf) + vim.api.nvim_win_set_height(new_win, vim.o.lines) -- full height, like native + term.closed = false + reapply_snacks_window_state(term, new_win) + if focus then + start_insert_if_terminal(term) + elseif vim.api.nvim_win_is_valid(original_win) then + vim.api.nvim_set_current_win(original_win) + end + return true +end + +---State stashed on a patched Snacks terminal instance. +---@class ClaudeCodeSnacksPatch +---@field orig_hide fun(self: table) Snacks' original Win:hide +---@field orig_show fun(self: table) Snacks' original Win:show +---@field orig_toggle fun(self: table) Snacks' original Win:toggle +---@field config table Effective terminal config captured at open time +---@field kind? "float"|"split" Window kind recorded at hide time + +-- Monkeypatch the Snacks terminal instance so hide/show/toggle -- including any +-- the user wires to Snacks keymaps (e.g. self:hide() in snacks_win_opts.keys) -- +-- use the anchor-preserving paths above instead of Snacks' destroy+recreate. +local function patch_instance(term, config) + term._cc = { + orig_hide = term.hide, + orig_show = term.show, + orig_toggle = term.toggle, + config = config, + } + function term:hide() + cc_hide(self) + return self + end + function term:show() + cc_show(self, true, self._cc and self._cc.config) + return self + end + function term:toggle() + if cc_is_visible(self) then + cc_hide(self) + else + cc_show(self, true, self._cc and self._cc.config) + end + return self + end +end + function M.setup() -- No specific setup needed for Snacks provider end @@ -94,36 +342,10 @@ function M.open(cmd_string, env_table, config, focus) focus = utils.normalize_focus(focus) if terminal and terminal:buf_valid() then - -- Check if terminal exists but is hidden (no window) - if not terminal.win or not vim.api.nvim_win_is_valid(terminal.win) then - -- Terminal is hidden, show it using snacks toggle - terminal:toggle() - if focus then - terminal:focus() - local term_buf_id = terminal.buf - if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then - if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then - vim.api.nvim_win_call(terminal.win, function() - vim.cmd("startinsert") - end) - end - end - end - else - -- Terminal is already visible - if focus then - terminal:focus() - local term_buf_id = terminal.buf - if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then - -- Check if window is valid before calling nvim_win_call - if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then - vim.api.nvim_win_call(terminal.win, function() - vim.cmd("startinsert") - end) - end - end - end - end + -- Reuse the existing terminal. Route through cc_show so a hidden terminal is + -- restored without Snacks destroying+recreating the window (which would climb + -- Claude's cursor -- #240/#183). + cc_show(terminal, focus, config) return end @@ -136,6 +358,7 @@ function M.open(cmd_string, env_table, config, focus) local term_instance = Snacks.terminal.open(cmd, opts) if term_instance and term_instance:buf_valid() then setup_terminal_events(term_instance, config) + patch_instance(term_instance, config) terminal = term_instance else terminal = nil @@ -186,17 +409,15 @@ function M.simple_toggle(cmd_string, env_table, config) local logger = require("claudecode.logger") - -- Check if terminal exists and is visible - if terminal and terminal:buf_valid() and terminal:win_valid() then - -- Terminal is visible, hide it - logger.debug("terminal", "Simple toggle: hiding visible terminal") - terminal:toggle() - elseif terminal and terminal:buf_valid() and not terminal:win_valid() then - -- Terminal exists but not visible, show it - logger.debug("terminal", "Simple toggle: showing hidden terminal") - terminal:toggle() + if terminal and terminal:buf_valid() then + if cc_is_visible(terminal) then + logger.debug("terminal", "Simple toggle: hiding visible terminal") + cc_hide(terminal) + else + logger.debug("terminal", "Simple toggle: showing hidden terminal") + cc_show(terminal, true, config) + end else - -- No terminal exists, create new one logger.debug("terminal", "Simple toggle: creating new terminal") M.open(cmd_string, env_table, config) end @@ -214,32 +435,21 @@ function M.focus_toggle(cmd_string, env_table, config) local logger = require("claudecode.logger") - -- Terminal exists, is valid, but not visible - if terminal and terminal:buf_valid() and not terminal:win_valid() then - logger.debug("terminal", "Focus toggle: showing hidden terminal") - terminal:toggle() - -- Terminal exists, is valid, and is visible - elseif terminal and terminal:buf_valid() and terminal:win_valid() then - local claude_term_neovim_win_id = terminal.win - local current_neovim_win_id = vim.api.nvim_get_current_win() - - -- you're IN it - if claude_term_neovim_win_id == current_neovim_win_id then + if terminal and terminal:buf_valid() then + if not cc_is_visible(terminal) then + -- Terminal exists but is hidden -> show and focus it. + logger.debug("terminal", "Focus toggle: showing hidden terminal") + cc_show(terminal, true, config) + elseif terminal.win == vim.api.nvim_get_current_win() then + -- You're IN it -> hide it. logger.debug("terminal", "Focus toggle: hiding terminal (currently focused)") - terminal:toggle() - -- you're NOT in it + cc_hide(terminal) else + -- Visible but not focused -> focus it. logger.debug("terminal", "Focus toggle: focusing terminal") - vim.api.nvim_set_current_win(claude_term_neovim_win_id) - if terminal.buf and vim.api.nvim_buf_is_valid(terminal.buf) then - if vim.api.nvim_buf_get_option(terminal.buf, "buftype") == "terminal" then - vim.api.nvim_win_call(claude_term_neovim_win_id, function() - vim.cmd("startinsert") - end) - end - end + vim.api.nvim_set_current_win(terminal.win) + start_insert_if_terminal(terminal) end - -- No terminal exists else logger.debug("terminal", "Focus toggle: creating new terminal") M.open(cmd_string, env_table, config) diff --git a/tests/unit/terminal/snacks_toggle_spec.lua b/tests/unit/terminal/snacks_toggle_spec.lua new file mode 100644 index 00000000..2bf246ae --- /dev/null +++ b/tests/unit/terminal/snacks_toggle_spec.lua @@ -0,0 +1,320 @@ +-- Tests for the Snacks provider hide/show/toggle logic that works around the +-- climbing-cursor bug (#240 split / #183 float). +-- +-- The provider keeps Claude's terminal cursor anchor stable by NOT letting Snacks +-- destroy+recreate the window via open_win(): +-- * float -> nvim_win_set_config({hide=true/false}) (window kept alive) +-- * split -> close on hide, recreate with vsplit + nvim_win_set_buf on show +-- These tests drive a self-contained Neovim mock that models window config +-- (relative/hide) so the float vs split branching can be asserted without a real +-- Neovim or Snacks. + +describe("claudecode.terminal.snacks hide/show/toggle", function() + local snacks_provider + local original_vim + local spy + local windows -- id -> { buf, config = { relative, hide }, valid } + local current_win + local next_win_id + local next_buf_id + local mock_snacks + local open_show_spy + + local function make_window(buf, opts) + local id = next_win_id + next_win_id = next_win_id + 1 + windows[id] = { + buf = buf, + valid = true, + config = { relative = (opts and opts.relative) or "", hide = false }, + } + return id + end + + -- A Snacks-like terminal instance backed by the mock window registry. + local function make_term_instance(opts) + local position = opts and opts.win and opts.win.position or "right" + local relative = position == "float" and "editor" or "" + local buf = next_buf_id + next_buf_id = next_buf_id + 1 + local win = make_window(buf, { relative = relative }) + current_win = win + local term + term = { + buf = buf, + win = win, + id = 1, + opts = { wo = {}, w = {}, position = position }, + buf_valid = function() + return vim.api.nvim_buf_is_valid(term.buf) + end, + win_valid = function() + return term.win ~= nil and vim.api.nvim_win_is_valid(term.win) + end, + on = function() end, + focus = function() end, + close = function() end, + -- Snacks originals that patch_instance captures; show recreates a window. + hide = function() + if term.win then + windows[term.win].valid = false + end + term.win = nil + end, + show = open_show_spy, + toggle = function() end, + } + return term + end + + before_each(function() + original_vim = vim + spy = require("luassert.spy") + windows = {} + current_win = 0 + next_win_id = 1000 + next_buf_id = 1 + + open_show_spy = spy.new(function(self) + -- Snacks re-creates the window from the original opts (e.g. a float). + local position = self.opts and self.opts.position or "right" + local relative = position == "float" and "editor" or "" + self.win = make_window(self.buf, { relative = relative }) + current_win = self.win + return self + end) + + _G.vim = { + log = { levels = { ERROR = 3, WARN = 2, INFO = 1, DEBUG = 0 } }, + notify = spy.new(function() end), + inspect = function(v) + return tostring(v) + end, + schedule = function(fn) + fn() + end, + o = { columns = 120, lines = 40 }, + w = setmetatable({}, { + __index = function(t, win) + rawset(t, win, rawget(t, win) or {}) + return rawget(t, win) + end, + }), + fn = { + has = function(feature) + return feature == "nvim-0.10" and 1 or 0 + end, + }, + cmd = function(c) + c = tostring(c) + if c:find("vsplit") then + local new_id = make_window(nil, { relative = "" }) + current_win = new_id + end + -- startinsert / wincmd p / noh: no-ops for these tests + end, + tbl_deep_extend = function(_, ...) + local res = {} + for _, t in ipairs({ ... }) do + if type(t) == "table" then + for k, v in pairs(t) do + res[k] = v + end + end + end + return res + end, + split = function(str, sep) + local result = {} + local start = 1 + while true do + local s, e = string.find(str, sep, start, true) + if not s then + table.insert(result, string.sub(str, start)) + break + end + table.insert(result, string.sub(str, start, s - 1)) + start = e + 1 + end + return result + end, + api = { + nvim_buf_is_valid = function(b) + return b ~= nil and b >= 1 + end, + nvim_buf_get_option = function() + return "terminal" + end, + nvim_win_is_valid = function(w) + return w ~= nil and windows[w] ~= nil and windows[w].valid + end, + nvim_win_get_config = function(w) + local win = windows[w] + if not win then + return {} + end + return { relative = win.config.relative, hide = win.config.hide } + end, + nvim_win_set_config = function(w, cfg) + if windows[w] then + for k, v in pairs(cfg) do + windows[w].config[k] = v + end + end + end, + nvim_win_close = function(w) + if windows[w] then + windows[w].valid = false + end + end, + nvim_win_get_buf = function(w) + return windows[w] and windows[w].buf + end, + nvim_win_set_buf = function(w, b) + if windows[w] then + windows[w].buf = b + end + end, + nvim_win_set_height = function() end, + nvim_get_current_win = function() + return current_win + end, + nvim_set_current_win = function(w) + current_win = w + end, + nvim_win_call = function(_, fn) + fn() + end, + }, + } + + mock_snacks = { + terminal = { + open = spy.new(function(_cmd, opts) + return make_term_instance(opts) + end), + }, + util = { wo = function() end }, + } + + package.loaded["snacks"] = mock_snacks + package.loaded["claudecode.logger"] = { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + } + package.loaded["claudecode.utils"] = nil + package.loaded["claudecode.terminal.snacks"] = nil + snacks_provider = require("claudecode.terminal.snacks") + end) + + after_each(function() + _G.vim = original_vim + package.loaded["snacks"] = nil + package.loaded["claudecode.utils"] = nil + package.loaded["claudecode.logger"] = nil + package.loaded["claudecode.terminal.snacks"] = nil + end) + + local function split_config() + return { cwd = "/w", split_side = "right", split_width_percentage = 0.3, auto_close = false, snacks_win_opts = {} } + end + local function float_config() + return { + cwd = "/w", + split_side = "right", + split_width_percentage = 0.3, + auto_close = false, + snacks_win_opts = { position = "float" }, + } + end + + it("split: simple_toggle hides by closing the window and shows by recreating it", function() + snacks_provider.open("claude", {}, split_config(), true) + local term = snacks_provider._get_terminal_for_test() + assert.is_not_nil(term.win) + local first_win = term.win + assert.is_true(windows[first_win].valid) + + -- Hide: window is closed, tracked win cleared. + snacks_provider.simple_toggle("claude", {}, split_config()) + assert.is_false(windows[first_win].valid) + assert.is_nil(term.win) + + -- Show: a fresh window is created via vsplit and the buffer is set in it. + snacks_provider.simple_toggle("claude", {}, split_config()) + assert.is_not_nil(term.win) + assert.is_true(windows[term.win].valid) + assert.are.equal(term.buf, windows[term.win].buf) + -- Snacks' own show() must NOT be used for a split (that path drifts). + assert.spy(open_show_spy).was_not_called() + end) + + it("float: simple_toggle config-hides (hide=true) and shows (hide=false) without closing", function() + snacks_provider.open("claude", {}, float_config(), true) + local term = snacks_provider._get_terminal_for_test() + local win = term.win + assert.are.equal("editor", windows[win].config.relative) + + snacks_provider.simple_toggle("claude", {}, float_config()) + assert.is_true(windows[win].valid) -- window kept alive + assert.is_true(windows[win].config.hide) -- just parked + assert.are.equal(win, term.win) + + snacks_provider.simple_toggle("claude", {}, float_config()) + assert.is_false(windows[win].config.hide) + assert.are.equal(win, term.win) + end) + + it("treats a config-hidden float as not visible (so a send/open re-shows it)", function() + snacks_provider.open("claude", {}, float_config(), true) + local term = snacks_provider._get_terminal_for_test() + windows[term.win].config.hide = true + -- get_active_bufnr still reports the buffer (terminal exists, just hidden)... + assert.are.equal(term.buf, snacks_provider.get_active_bufnr()) + -- ...and a focus toggle un-hides rather than treating it as visible. + snacks_provider.simple_toggle("claude", {}, float_config()) + assert.is_false(windows[term.win].config.hide) + end) + + it("split close that errors (E444 last window) does not throw and keeps state sane", function() + snacks_provider.open("claude", {}, split_config(), true) + local term = snacks_provider._get_terminal_for_test() + local win = term.win + -- Simulate "cannot close last window". + vim.api.nvim_win_close = function() + error("E444: Cannot close last window") + end + assert.has_no.errors(function() + snacks_provider.simple_toggle("claude", {}, split_config()) + end) + -- Close failed, so the window stays and remains tracked (no desync). + assert.are.equal(win, term.win) + end) + + it("externally-closed float reopens as a float (via Snacks), not a split", function() + snacks_provider.open("claude", {}, float_config(), true) + local term = snacks_provider._get_terminal_for_test() + -- External close (e.g. :q): window dies but buffer survives; no cc_hide ran. + windows[term.win].valid = false + term.win = nil + + snacks_provider.simple_toggle("claude", {}, float_config()) + -- Recreated through Snacks (preserves float opts), NOT the vsplit path. + assert.spy(open_show_spy).was_called() + assert.are.equal("editor", windows[term.win].config.relative) + end) + + it("reuses the existing terminal on a second open() without error", function() + snacks_provider.open("claude", {}, split_config(), true) + local term = snacks_provider._get_terminal_for_test() + local win = term.win + assert.has_no.errors(function() + snacks_provider.open("claude", {}, split_config(), true) + end) + -- Still the same visible window (no destroy/recreate when already visible). + assert.are.equal(win, term.win) + assert.is_true(windows[win].valid) + end) +end) From b258097c742e4cae4258e0dd5104f26edec719ab Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 9 Jun 2026 10:52:10 +0200 Subject: [PATCH 2/3] test(fixtures): add cursor-toggle-repro harness for #240/#183 Self-contained agent-tty reproduction of the Snacks climbing-cursor bug: - init.lua: minimal LazyVim-style Snacks fixture (provider/position/cmd env knobs) - box.py: auth-free instrument that enables focus reporting and logs every input byte + SIGWINCH, proving the trigger is focus churn on window recreate, not a pty resize - agent-repro.sh: drives a real Claude CLI and prints the cursor-vs-prompt delta per toggle for snacks (split & float) vs native - README.md: verdict, root-cause chain, and measured results Change-Id: I2596d57c56b22d937e744e6be56f42a7735666fa Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- fixtures/cursor-toggle-repro/README.md | 190 +++++++++ .../__pycache__/box.cpython-314.pyc | Bin 0 -> 6231 bytes fixtures/cursor-toggle-repro/agent-repro.sh | 205 ++++++++++ .../cursor-toggle-repro/box-delta-check.sh | 90 +++++ .../cursor-toggle-repro/box-float-check.sh | 64 +++ fixtures/cursor-toggle-repro/box.py | 116 ++++++ .../cursor-toggle-repro/float-fix-probe.sh | 111 ++++++ fixtures/cursor-toggle-repro/float-repro.sh | 153 +++++++ fixtures/cursor-toggle-repro/init.lua | 373 ++++++++++++++++++ fixtures/cursor-toggle-repro/sample.txt | 60 +++ 10 files changed, 1362 insertions(+) create mode 100644 fixtures/cursor-toggle-repro/README.md create mode 100644 fixtures/cursor-toggle-repro/__pycache__/box.cpython-314.pyc create mode 100755 fixtures/cursor-toggle-repro/agent-repro.sh create mode 100755 fixtures/cursor-toggle-repro/box-delta-check.sh create mode 100755 fixtures/cursor-toggle-repro/box-float-check.sh create mode 100755 fixtures/cursor-toggle-repro/box.py create mode 100755 fixtures/cursor-toggle-repro/float-fix-probe.sh create mode 100755 fixtures/cursor-toggle-repro/float-repro.sh create mode 100644 fixtures/cursor-toggle-repro/init.lua create mode 100644 fixtures/cursor-toggle-repro/sample.txt diff --git a/fixtures/cursor-toggle-repro/README.md b/fixtures/cursor-toggle-repro/README.md new file mode 100644 index 00000000..f0bb0c32 --- /dev/null +++ b/fixtures/cursor-toggle-repro/README.md @@ -0,0 +1,190 @@ +# cursor-toggle-repro — triage & reproduction for #240 / #183 + +**Issues:** +[#240](https://github.com/coder/claudecode.nvim/issues/240) — "When re-opening the +Claude side panel, the cursor is one line higher than it should be" (vertical split). +[#183](https://github.com/coder/claudecode.nvim/issues/183) — "Input cursor in +floating mode moves upwards every time I toggle" (float). **Same root cause.** + +## TL;DR verdict + +With the **Snacks** terminal provider (LazyVim's default), hiding and re-showing the +Claude window leaves the terminal cursor **one row above** Claude's `❯` input prompt, +so typed text lands on the wrong line and the prompt box visibly corrupts. The +plugin's **native** provider does **not** show this. + +The popular community fixes (RasmusN's fork, mwojick's gist) attribute the bug to a +**`SIGWINCH`/pty resize** when Snacks destroys the window. **That is not what happens +here** — instrumenting the inner PTY shows **zero `SIGWINCH`** on toggle. The real +chain is: + +1. Snacks hides the panel by **closing the window** (`nvim_win_close(win, true)`) and + re-shows it by **recreating** it — for both splits and floats. Chain in + `snacks.nvim/lua/snacks/win.lua`: `Win:hide` (619) → `Win:close({buf=false})` (542, + `nvim_win_close` at 562) → `Win:show` (819) → `open_win`. Snacks has **no + hide-without-close** option. The native provider also closes/reopens, but does not + trigger the drift. +2. On hide Neovim sends **focus-out** (`ESC[O` / `CSI O`) and on show **focus-in** + (`ESC[I` / `CSI I`) to the child, because Claude enables focus reporting + (DECSET `?1004`). (Confirmed in Neovim source: `terminal_focus()` → + `vterm_state_focus_in/out()` → bytes to the child via `term_output_callback`.) +3. Claude Code (built on **Ink**, which redraws **relative** to the cursor) re-renders + its TUI on focus-in. After the window was destroyed/recreated, its cursor anchor is + off by one, so the relative redraw lands one row too high — and keeps climbing. + +Two facts pin the layers: + +- **Focus change alone does not drift.** Moving Neovim focus editor↔terminal _without_ + hiding the window never drifts. The window destroy+recreate is a required co-factor. +- **Absolute-positioning programs do not drift; only Claude does.** _Measured_ + (`box-delta-check.sh`): a synthetic TUI that redraws with absolute cursor moves + (`CSI row;col H`) keeps its cursor on its `>` prompt row (cursorRow=35) across every + toggle — zero drift — under the identical Snacks float churn that moves real Claude by + one row. This rules out a Neovim PTY/window coordinate bug and pins the drift to + Claude's **cursor-relative Ink repaint**. Consistent with the community report that + **downgrading Claude to `2.0.76` makes it disappear**. So this is substantially a + **Claude-CLI-side** rendering behavior that Snacks' window churn exposes; the plugin + can only _work around_ it. +- **"Destroy/recreate" is not the _sole_ discriminator — _how_ Snacks recreates is.** + The native provider _also_ closes the window on hide (`nvim_win_close`, + `native.lua:193`) and creates a _new_ window on show (`vsplit` + `nvim_win_set_buf`, + `native.lua:227-232`) reusing the same terminal buffer — yet it does **not** drift. + Snacks re-shows a float via `nvim_open_win` (`win.lua:733`), which is what resets the + cursor/scroll anchor. The snacks-only A/B (close+recreate → delta 1; config-hide → + delta 0) isolates the recreate within the snacks float; native shows a _different_ + recreate path is immune. (RasmusN's fork also `nvim_win_set_cursor`-scrolls to bottom + and defers `startinsert`, hinting the new float window's scroll/cursor view on re-show + is the proximate anchor shift.) + +| layer | role | +| ------------------------ | ------------------------------------------------------------------------------------------------ | +| Claude CLI ≥ 2.1.x (Ink) | re-renders relative-to-cursor on focus-in → the actual climb; older 2.0.76 did not | +| Snacks provider | hide=close-window, show=recreate-window → disturbs the cursor anchor that Claude redraws against | +| Neovim | forwards focus-out/in to the child on window hide/show (no resize) | + +Each link in this chain was re-checked against primary sources (the Neovim 0.12.2 +binary's terminal/focus source, the pinned snacks.nvim source, and api.txt) by an +independent adversarial pass; all held. The one thing the community fixes get wrong is +the _cause_ (they say `SIGWINCH`); their _mechanism_ (stop destroying the window) is +right anyway, because it preserves the cursor anchor. + +## Reproduce it + +### A. Automated (agent-tty) + +```bash +fixtures/cursor-toggle-repro/agent-repro.sh +``` + +- **PART A (no auth):** runs `box.py` (a synthetic TUI that enables focus reporting and + logs every byte + `SIGWINCH`) as the terminal command and toggles the window. Expected: + `SIGWINCH events on toggle: 0`, with `FOCUS_IN`/`FOCUS_OUT` on every cycle — proving + the trigger is focus churn, not a resize. +- **PART B (needs a logged-in `claude`):** runs the real Claude CLI under both providers + and prints the cursor-vs-prompt `delta` after each toggle: + + ``` + -- provider=snacks -- + baseline: cursorRow=9 promptRow=9 delta=0 + after toggle 1: cursorRow=8 promptRow=9 delta=1 <- BUG (cursor one row above ❯) + -- provider=native -- + baseline: cursorRow=9 promptRow=9 delta=0 + after toggle 1: cursorRow=9 promptRow=9 delta=0 <- fine + ``` + +#### #183 float, measured this session (Claude 2.1.168, nvim 0.12.2, current `main`) + +```text +$ ./float-repro.sh # the bug +== provider=snacks (float) == + baseline: delta=0 + after toggle 1..5: delta=1 <- cursor one row ABOVE ❯ on every toggle + final: typed "ZZZQ" rendered as "──ZZZQ" ON THE BOX TOP BORDER (row 9), ❯ on row 10 +== provider=native (float) == + baseline..toggle 5: delta=0 <- fine; "ZZZQ" rendered as "❯ ZZZQ" + +$ ./float-fix-probe.sh # the candidate fix (config-hide) +== provider=snacks (float) + CONFIG-HIDE == + baseline..toggle 5: delta=0 <- FIXED; "ZZZQ" rendered as "❯ ZZZQ" + +$ ./box-float-check.sh # instrument: not a resize + SIGWINCH events on toggle: 0 FOCUS_IN: 4 FOCUS_OUT: 4 (4 cycles) + +$ ./box-delta-check.sh # control: absolute-positioning TUI is immune +== box.py (absolute CSI row;col H) under snacks float == + baseline..toggle 3: cursorRow=35 (stable) — cursor stays ON its "> " prompt row, NO drift +``` + +The snacks-vs-config-hide A/B holds the focus flow identical (move to editor → hide → +re-show+focus); the only difference is whether the window is **destroyed** or **kept**, so +the destroy/recreate is the trigger. `box-float-check.sh` confirms the toggle is **not** a +pty resize. (Note: in this automated flow the snacks/float drift stabilizes at delta=1 — +each toggle re-introduces a 1-row error rather than climbing unbounded; the user-visible +symptom, "typed text lands on the wrong line after a toggle," is the same.) + +### B. Manual (interactive) + +```bash +cd fixtures && NVIM_APPNAME=cursor-toggle-repro XDG_CONFIG_HOME="$PWD" \ + mise exec -- nvim cursor-toggle-repro/sample.txt +``` + +1. `ac` opens the Claude terminal (Snacks split). +2. `` then `h` to the editor; `ac` to hide, `ac` to show. +3. The `❯` prompt is drawn where it was, but the cursor (and anything you type) is now one + row higher. Toggle again to see it worsen / corrupt the box. + +Env knobs (see `init.lua`): `CURSOR_REPRO_PROVIDER` (`snacks`|`native`), +`CURSOR_REPRO_POSITION` (`right`|`float` = #183), `CURSOR_REPRO_CMD` (run `box.py` +instead of `claude`), `CURSOR_REPRO_BORDER`. `:ReproCursorInfo` / `:ReproWinDiag` dump +geometry to `$CURSOR_REPRO_LOG`. + +## Fixes & workarounds + +> Validated here = measured to keep `delta=0` across toggles with real Claude. + +1. **Config-hide (validated for floats — fixes #183).** Hide/show via + `nvim_win_set_config(win, {hide=true/false})` instead of closing+recreating the + window. Keeping the window object alive preserves the grid + cursor anchor, so Claude's + focus-in redraw stays aligned. This is what RasmusN's fork and mwojick's "parking + float" do (their _stated_ reason — avoiding `SIGWINCH` — is wrong, but the fix works for + a different reason: it preserves the anchor). **Caveat:** `{hide=true}` does **not** + visually hide a _non-floating split_ in Neovim 0.12.2 (the window stays visible), so this + path is a clean fix for **floats only**. Re-confirmed this session on Claude 2.1.168 via + `float-fix-probe.sh` (delta stays 0 across 5 toggles; typed text lands after `❯`). + **Plugin-integration caveat:** a config-hidden window is still `nvim_win_is_valid()==true`, + so the snacks provider's `simple_toggle`/`focus_toggle` visibility checks + (`terminal:win_valid()`) would treat it as still-visible. A real plugin fix must gate on + `nvim_win_get_config(win).hide` (what the fixture's `:ReproConfigHideToggle` does) or track + hidden state, and must manage the window directly rather than via `terminal:toggle()`. + +2. **Use the native provider (workaround for #240 split users, today).** + `terminal = { provider = "native" }` — does not drift. Loses Snacks' float/UI niceties. + +3. **Downgrade Claude CLI to `2.0.76` (workaround).** Confirms the bug is in Claude's + newer focus-driven redraw; not a long-term fix. + +4. **Upstream (the real fix):** Claude Code's focus-in re-render should not depend on a + cursor anchor that can move; this is the layer that regressed between 2.0.76 and 2.1.x. + +**What did NOT work:** `start_insert=false` + scroll-to-bottom + deferred `startinsert` +(RasmusN's split-side change) — still `delta=1` here. Setting `border="none"` (matching the +native row count) — still `delta=1`. So neither the insert timing nor the 1-row height +difference is the cause. + +## Files + +- `init.lua` — fixture config (Snacks provider; loads the local plugin + snacks via rtp). + Also defines `:ReproConfigHideToggle` / `ah`, the candidate-fix probe that + hides the float via `nvim_win_set_config{hide=…}` instead of closing the window. +- `box.py` — synthetic TUI / instrument: enables focus reporting, logs input bytes + SIGWINCH. +- `sample.txt` — filler content for the "main editor" window. +- `agent-repro.sh` — self-contained automated reproduction for the **split** (#240): + PART A (box.py, no-auth) + PART B (real Claude, snacks vs native). +- `float-repro.sh` — **#183-specific** automated reproduction: Snacks `position="float"`, + real Claude, hides+re-shows N times and prints the cursor-vs-`❯` delta. snacks→delta 1, + native→delta 0; ends by typing `ZZZQ` to show where input lands. +- `float-fix-probe.sh` — validates the candidate fix: same float harness but toggles via + `ah` (config-hide). Measures whether delta stays 0. +- `box-float-check.sh` — instrument refresh on the float: counts SIGWINCH vs focus + events across snacks close+recreate toggles (proves 0 SIGWINCH, focus churn present). diff --git a/fixtures/cursor-toggle-repro/__pycache__/box.cpython-314.pyc b/fixtures/cursor-toggle-repro/__pycache__/box.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c6d81565dfe70ca5b541e36307193d385c0e2760 GIT binary patch literal 6231 zcmc&&Z*UXG72ngHWJ#9%SNIPy_89@Oz!nBM3IC}vU<}w+Pevp`v3iou_EG6h?46M8 z>9itEf?=52BU~vtAf0avqE0~VgqN}@f<>NSWg>zvAz@a6-jmg&mR5pR`VNx zbZ}Zyrvz1uu>%P{9T$2ObvhxiV<(2#v?9b=Rc1v=QE@scNGc0m*;qnMMn!3|Czi&F zjM->C`S(koafB<7RQ4LBgpK?(R* ziqd5>q7;`igyxhOk3$!-4h&MCk~5OMOOoLQ=EQ;`&IoLVS6Df!2>7fJ_ja?2%w|CT zyu=FGRDu^Jm}T0a3`8)nBVa|R2~*pPQqdKrfO3w@lORNOY8Sg<-~<;ObV6mfs>#&WsGRkI2tlhOn(J}>XB?INVusO@lI#{5p8J10ld=rv&&$pq0USd^}d)U;;SXTijKtc6E823Zc>H8JLvRcGz8v-7sI^P1;Zn}4x+ zdA*ul;vR514TKneO#ahpz)$`eEb;AQhnOjzBn=jVhrJ?q@ z(g5^c>bLbf&<%&Pzm2}(rXY`1YK+KI*Q^!z&Q<7joazRjX(R=ONiRJan#uqj>M2~(G)Ea;ZZ{PH`KRef$z!FM5c4?wbEdLQoiv@?7B1=jaaK_FfPPDNXjAyf zO`BMj#kFK!CITB3uY>A(coYt@9Qv5C&i21DlsmXu-}HL(xua0AIWNWL z{d4V?+OKY3vD)XX7p#l*eScuyiQRPNLoY0cBDX`4r4YaPZ1hfjG;fXllScNIx%RpC zi|wB))j;-lef_=W8@<&BC|e9(ONodSM919!35e+%m<1I=K zfOd>I6je+l?1jJ#V+bB7K#Hdnf;1qwPqT7iA(w^;WHlaMJx$X;FPi(<4*fbQ3`g8B=xs=((e_Kgcs3#rk6H zf%hhE#upDgf2a2OJo7@S*_&rJ8~wk(;JQxzvgNveq4rMg);!bu$cH<%oAb<;M;iaP z_xG-<3;fTscWS%xjCZxb%QEA-#kl6VDc|_n#{6`A>M~VU< zOE8k20xqLgE;|8)8Y)RqVX_JI6lgJO<+8HxL!7mj`xv!Ar%@}HoXME!iK9K`QAVvo z${UTg$`~0oa8HE?brT>@_NJZ!rJB(~fP4t~C?E16j_D&?8&U?WAqB80MV~b7g!f5! zZ$rm`ewgfWJq5arTDc^&IJ2QU0s{?}3Q%k~3&3z|xxY~}R+chzmcTZnwOo>ea)_xb zt;>*N$`US1!phk~=3SHnarO|i%LHY0$U@$?TMUjMqBuefSy3GH+=ePfI}Mtk8H4H4 zd@!!2I1a4wp>eh4aUq7QuaFj;qd@j$>;0-WQdg$j&Dk3o)U>(C+U_)m>P8qoN^$!3fa zU?)6THe;3Re2i?yDr6&;kDm=1l^L0-bD!EzE<~oFBo`o4nmIVu&y7v`^|Z8KM6lg3 z3!;jZJTAPDJ?Mw${&yf-4|2<)F?4tX>_}>y9fPCRZqtrkV8P%&+tBzAbOa7=z6}k- zjvcNpdvDNOk?t_XQCXBTcc5dN5VE0m#KB?uX&R2o^d~G#qfx#QINQH9h z0-X0UaPA{F1ek#3GM>W3Ka~SG>6{Q^DkfnNj)(#u*JxNe2K6FB8EEO0ZDRw${)1fq z(1^DRj*rkwPQ*0|Yg9yI;=BsyOBe>sqySl}`mJ0QkRY%OQt^mM6orG(JP42S0Z2lJ z0+$+RV?P{OW?F7BEqP|`Dq}xC{L=6no33oVy!G17Jk#^B%`w+{p>^5Tddt?jWOL^Z ztk`RE1LV@exmvSkxu)ZGO~-<1sm3!)uUf0;>=*6x+b`8Tup_gTptk~IT3pkUXM7Jq zr99I_j=0S)4+9c;dGIdNWZtz>)3Dmqb|v{*a)Dboczr#XjAyConG1un{j+E0JK-MW zN14^;)+?FUGFS0J-%@keJoEQv_lm1^rP+NYdpZ01%sjKw*8bMun}fHA)h&5~OKrh< z+iIQb^2y)#z3ck(^kVzB^L5-ujcp4|zH!5DFWN{bl%hr}#Rv5D_Z{4o1*J#RH*RE98T-?9vXk2!zyX{zaHG1u-!adI0 z!S@|IKc$hw{n>;4$myCJzA*f=XBVqIrBMs=?uJuRy(0jG*B=`0Eq4>)t3i6u`?jzgio4pjt`}#K@ZKpMBBtont67f>H zC-OFGRgp+sjzuE6jTVjv%|IM~i(~{;;)5h3Hlb90(1~nYMTQLIL{XnIc8a;*ged!m z2>#U{BIk#wMz1?RvRKcbK6m=ObWU1av*r5ul4VbhUa7L?m=&8nXSruFQ!Nkb%v7AZ oSGR_0zSnD_9QW*AYS-Q8D8x8sXYzFG2aNUA!=No@t/dev/null 2>&1; then + NVIM="mise exec -- nvim" +else + NVIM="nvim" +fi + +command -v agent-tty >/dev/null || { + echo "ERROR: agent-tty not found on PATH" + exit 1 +} +command -v python3 >/dev/null || { + echo "ERROR: python3 not found on PATH" + exit 1 +} +# shellcheck disable=SC2086 +$NVIM --version >/dev/null 2>&1 || { + echo "ERROR: could not run Neovim ('$NVIM'); set NVIM_BIN" + exit 1 +} + +WORK="$(mktemp -d)" +AGENT_HOME="$WORK/atty-home" +mkdir -p "$AGENT_HOME" +trap 'agent-tty --home "$AGENT_HOME" gc --json >/dev/null 2>&1; rm -rf "$WORK"' EXIT + +# A small numbered file so the "main editor" window has visible content. +python3 - "$WORK/sample.txt" <<'PY' +import sys +open(sys.argv[1], "w").write("\n".join("line %02d" % i for i in range(1, 61)) + "\n") +PY + +a=(agent-tty --home "$AGENT_HOME") + +# Parse a snapshot into "cursorRow promptRow delta". promptRow = last visible row +# whose text contains the marker ($1, default ❯). +measure() { # measure [marker] + local sid="$1" marker="${2:-❯}" + "${a[@]}" snapshot "$sid" --json 2>/dev/null | MARK="$marker" python3 -c ' +import json,os,sys +mark=os.environ["MARK"] +r=json.load(sys.stdin)["result"] +rows=[l["row"] for l in r["visibleLines"] if mark in l["text"]] +prow=rows[-1] if rows else None +cur=r["cursorRow"] +delta=(prow-cur) if prow is not None else None +print(f"cursorRow={cur} promptRow={prow} delta={delta}") +' +} + +# escape terminal-insert -> normal, move to the editor window, then toggle hide+show. +toggle_cycle() { # toggle_cycle + local sid="$1" + "${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 + "${a[@]}" send-keys "$sid" 'C-w' 'h' --json >/dev/null 2>&1 + "${a[@]}" wait "$sid" --screen-stable-ms 400 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 # ac -> hide + "${a[@]}" wait "$sid" --screen-stable-ms 500 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 # ac -> show + "${a[@]}" wait "$sid" --screen-stable-ms 800 --json >/dev/null 2>&1 +} + +launch() { # launch -> echoes sid + local prov="$1" cmd="${2:-}" boxlog="${3:-}" position="${4:-}" + local env="CURSOR_REPRO_PROVIDER=$prov NVIM_APPNAME=cursor-toggle-repro XDG_CONFIG_HOME='$FIX_DIR'" + [ -n "$cmd" ] && env="$env CURSOR_REPRO_CMD='$cmd'" + [ -n "$boxlog" ] && env="$env CURSOR_REPRO_BOX_LOG='$boxlog'" + [ -n "$position" ] && env="$env CURSOR_REPRO_POSITION='$position'" + "${a[@]}" create --json --cols 120 --rows 40 -- \ + bash -lc "cd '$FIX_DIR' && $env $NVIM '$WORK/sample.txt'" | + python3 -c "import json,sys;print(json.load(sys.stdin)['result']['sessionId'])" +} + +# Count visible lines matching Claude's footer -- 0 means the panel is hidden. +claude_panel_lines() { # claude_panel_lines + "${a[@]}" snapshot "$1" --json 2>/dev/null | + python3 -c 'import json,sys; r=json.load(sys.stdin)["result"]; print(sum(1 for l in r["visibleLines"] if "auto mode" in l["text"]))' +} + +# shellcheck disable=SC2086 +echo "Neovim: $($NVIM --version | head -1)" +echo + +############################################################################ +echo "== PART A: what does Neovim send to the terminal on hide/show? (no auth) ==" +BOXLOG="$WORK/box.log" +sid="$(launch snacks "python3 $HERE/box.py" "$BOXLOG")" +"${a[@]}" wait "$sid" --screen-stable-ms 1500 --json >/dev/null 2>&1 +"${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 +"${a[@]}" wait "$sid" --text 'synthetic claude' --timeout-ms 15000 --json >/dev/null 2>&1 +"${a[@]}" wait "$sid" --screen-stable-ms 700 --json >/dev/null 2>&1 +for _ in $(seq 1 "$CYCLES"); do toggle_cycle "$sid"; done +"${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 +"${a[@]}" type "$sid" ':qa!' --json >/dev/null 2>&1 +"${a[@]}" send-keys "$sid" Enter --json >/dev/null 2>&1 +sleep 0.3 +"${a[@]}" destroy "$sid" --json >/dev/null 2>&1 +# grep -c prints "0" and exits 1 when there are no matches; `|| true` keeps that +# single "0" without appending another, and ${x:-0} covers a missing file. +winch="$(grep -c SIGWINCH "$BOXLOG" 2>/dev/null || true)" +winch="${winch:-0}" +fin="$(grep -c 'FOCUS_IN' "$BOXLOG" 2>/dev/null || true)" +fin="${fin:-0}" +fout="$(grep -c 'FOCUS_OUT' "$BOXLOG" 2>/dev/null || true)" +fout="${fout:-0}" +echo " SIGWINCH events on toggle: $winch (expect 0 -> NOT a pty resize)" +echo " FOCUS_IN (ESC[I) events: $fin" +echo " FOCUS_OUT (ESC[O) events: $fout (focus churn is the real trigger)" +echo + +############################################################################ +echo "== PART B: real Claude — does the cursor climb? (needs logged-in claude) ==" +if ! command -v claude >/dev/null 2>&1; then + echo " SKIP: 'claude' not on PATH." +else + for prov in snacks native; do + echo " -- provider=$prov --" + sid="$(launch "$prov" "" "")" + "${a[@]}" wait "$sid" --screen-stable-ms 1500 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 + if ! "${a[@]}" wait "$sid" --text 'auto mode' --timeout-ms 25000 --json >/dev/null 2>&1; then + echo " SKIP: Claude did not reach its prompt (not logged in?)." + "${a[@]}" destroy "$sid" --json >/dev/null 2>&1 + continue + fi + "${a[@]}" wait "$sid" --screen-stable-ms 1300 --json >/dev/null 2>&1 + echo " baseline: $(measure "$sid")" + for i in $(seq 1 "$CYCLES"); do + toggle_cycle "$sid" + echo " after toggle $i: $(measure "$sid")" + done + "${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ':qa!' --json >/dev/null 2>&1 + "${a[@]}" send-keys "$sid" Enter --json >/dev/null 2>&1 + sleep 0.3 + "${a[@]}" destroy "$sid" --json >/dev/null 2>&1 + done + + # Float variant (#183): also assert the panel actually hides each cycle. + echo " -- provider=snacks position=float (#183) --" + sid="$(launch snacks "" "" float)" + "${a[@]}" wait "$sid" --screen-stable-ms 1500 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 + if "${a[@]}" wait "$sid" --text 'auto mode' --timeout-ms 25000 --json >/dev/null 2>&1; then + "${a[@]}" wait "$sid" --screen-stable-ms 1300 --json >/dev/null 2>&1 + echo " baseline: $(measure "$sid")" + for i in $(seq 1 "$CYCLES"); do + "${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 # hide (float config-hide) + "${a[@]}" wait "$sid" --screen-stable-ms 600 --json >/dev/null 2>&1 + hidden_lines="$(claude_panel_lines "$sid")" + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 # show + "${a[@]}" wait "$sid" --screen-stable-ms 900 --json >/dev/null 2>&1 + echo " cycle $i: hidden_footer_lines=$hidden_lines (expect 0) ; shown-> $(measure "$sid")" + done + else + echo " SKIP: Claude did not reach its prompt (not logged in?)." + fi + "${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ':qa!' --json >/dev/null 2>&1 + "${a[@]}" send-keys "$sid" Enter --json >/dev/null 2>&1 + sleep 0.3 + "${a[@]}" destroy "$sid" --json >/dev/null 2>&1 + + echo + echo " Interpretation: with the fix, snacks (split & float) holds delta=0 like native," + echo " and the float reports hidden_footer_lines=0 on hide (panel truly hidden)." +fi diff --git a/fixtures/cursor-toggle-repro/box-delta-check.sh b/fixtures/cursor-toggle-repro/box-delta-check.sh new file mode 100755 index 00000000..671badc6 --- /dev/null +++ b/fixtures/cursor-toggle-repro/box-delta-check.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Closes the adversarial gap flagged in the #183 deep-research review: we had +# measured box.py's FOCUS/SIGWINCH events but NOT its visual cursor delta. This +# runs box.py (synthetic TUI using ABSOLUTE positioning, CSI row;col H) under the +# Snacks float and measures cursorRow vs its "> " prompt row across snacks +# close+recreate toggles — the same churn that drifts the real Claude CLI by 1 row. +# +# If box.py stays at delta=0, absolute positioning is immune and the drift is +# Claude's cursor-RELATIVE Ink repaint (chain holds). If box.py ALSO drifts, the +# cause is a Neovim PTY/window coordinate mismatch, not Ink — and the whole +# diagnosis flips. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIX_DIR="$(dirname "$HERE")" +CYCLES="${CYCLES:-5}" +export _ZO_DOCTOR=0 +if [ -n "${NVIM_BIN:-}" ]; then + NVIM="$NVIM_BIN" +elif command -v mise >/dev/null 2>&1; then + NVIM="mise exec -- nvim" +else NVIM="nvim"; fi + +WORK="${CLAUDE_JOB_DIR:-/tmp}/tmp/box-delta.$$" +rm -rf "$WORK" +mkdir -p "$WORK" +AGENT_HOME="$WORK/atty-home" +mkdir -p "$AGENT_HOME" +trap 'agent-tty --home "$AGENT_HOME" gc --json >/dev/null 2>&1; rm -rf "$WORK"' EXIT +python3 - "$WORK/sample.txt" <<'PY' +import sys +open(sys.argv[1], "w").write("\n".join("line %02d" % i for i in range(1, 61)) + "\n") +PY +a=(agent-tty --home "$AGENT_HOME") + +# box.py draws "> " (strips to ">") on its prompt row and parks the cursor there. +measure() { + "${a[@]}" snapshot "$1" --json 2>/dev/null | python3 -c ' +import json,sys +r=json.load(sys.stdin)["result"] +rows=[l["row"] for l in r["visibleLines"] if "> " in l["text"]] +prow=rows[-1] if rows else None +cur=r["cursorRow"] +delta=(prow-cur) if prow is not None else None +print(f"cursorRow={cur} promptRow={prow} delta={delta}") +' +} +dump() { + "${a[@]}" snapshot "$1" --json 2>/dev/null | python3 -c ' +import json,sys +r=json.load(sys.stdin)["result"] +print("cursorRow=%s cursorCol=%s"%(r.get("cursorRow"),r.get("cursorCol"))) +for l in r["visibleLines"]: + t=l["text"].rstrip() + if t: print("%3d| %s"%(l["row"], t)) +' +} +toggle_cycle() { + local sid="$1" + "${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 + "${a[@]}" send-keys "$sid" 'C-w' 'h' --json >/dev/null 2>&1 + "${a[@]}" wait "$sid" --screen-stable-ms 400 --timeout-ms 8000 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 + "${a[@]}" wait "$sid" --screen-stable-ms 500 --timeout-ms 8000 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 + "${a[@]}" wait "$sid" --screen-stable-ms 800 --timeout-ms 8000 --json >/dev/null 2>&1 +} +sid="$("${a[@]}" create --json --cols 120 --rows 40 -- \ + bash -lc "cd '$FIX_DIR' && CURSOR_REPRO_PROVIDER=snacks CURSOR_REPRO_POSITION=float CURSOR_REPRO_CMD='python3 $HERE/box.py' NVIM_APPNAME=cursor-toggle-repro XDG_CONFIG_HOME='$FIX_DIR' $NVIM '$WORK/sample.txt'" | + python3 -c "import json,sys;print(json.load(sys.stdin)['result']['sessionId'])")" +echo "Neovim: $($NVIM --version | head -1) (box.py = absolute-positioning synthetic TUI, snacks float)" +"${a[@]}" wait "$sid" --screen-stable-ms 1500 --timeout-ms 15000 --json >/dev/null 2>&1 +"${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 +"${a[@]}" wait "$sid" --text 'synthetic claude' --timeout-ms 15000 --json >/dev/null 2>&1 +"${a[@]}" wait "$sid" --screen-stable-ms 700 --timeout-ms 8000 --json >/dev/null 2>&1 +echo " baseline: $(measure "$sid")" +for i in $(seq 1 "$CYCLES"); do + toggle_cycle "$sid" + echo " after toggle $i: $(measure "$sid")" +done +echo " --- final screen (box.py absolute prompt) ---" +dump "$sid" | sed 's/^/ /' +"${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 +"${a[@]}" type "$sid" ':qa!' --json >/dev/null 2>&1 +"${a[@]}" send-keys "$sid" Enter --json >/dev/null 2>&1 +sleep 0.3 +"${a[@]}" destroy "$sid" --json >/dev/null 2>&1 +echo +echo "delta=0 across toggles -> absolute positioning IS immune -> drift is Claude's relative repaint (chain holds)." +echo "delta!=0 -> a Neovim PTY/window coordinate mismatch, NOT Ink -> diagnosis flips." diff --git a/fixtures/cursor-toggle-repro/box-float-check.sh b/fixtures/cursor-toggle-repro/box-float-check.sh new file mode 100755 index 00000000..4c877a11 --- /dev/null +++ b/fixtures/cursor-toggle-repro/box-float-check.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# #183 instrument refresh: run box.py (synthetic TUI that enables focus reporting +# DECSET ?1004 and logs every input byte + SIGWINCH) as the terminal command under +# the Snacks FLOAT, toggle it CYCLES times via the normal Snacks close+recreate +# path (ac), and report SIGWINCH vs FOCUS event counts. Confirms (freshly, +# on the current snacks/nvim) that the toggle sends focus-out/in but NO SIGWINCH, +# i.e. the "pty resize / SIGWINCH" the community fixes blame does not occur. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIX_DIR="$(dirname "$HERE")" +CYCLES="${CYCLES:-4}" +export _ZO_DOCTOR=0 +if [ -n "${NVIM_BIN:-}" ]; then + NVIM="$NVIM_BIN" +elif command -v mise >/dev/null 2>&1; then + NVIM="mise exec -- nvim" +else NVIM="nvim"; fi + +WORK="${CLAUDE_JOB_DIR:-/tmp}/tmp/box-float.$$" +rm -rf "$WORK" +mkdir -p "$WORK" +AGENT_HOME="$WORK/atty-home" +mkdir -p "$AGENT_HOME" +BOXLOG="$WORK/box.log" +trap 'agent-tty --home "$AGENT_HOME" gc --json >/dev/null 2>&1; rm -rf "$WORK"' EXIT +python3 - "$WORK/sample.txt" <<'PY' +import sys +open(sys.argv[1], "w").write("\n".join("line %02d" % i for i in range(1, 61)) + "\n") +PY +a=(agent-tty --home "$AGENT_HOME") +toggle_cycle() { + local sid="$1" + "${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 + "${a[@]}" send-keys "$sid" 'C-w' 'h' --json >/dev/null 2>&1 + "${a[@]}" wait "$sid" --screen-stable-ms 400 --timeout-ms 8000 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 + "${a[@]}" wait "$sid" --screen-stable-ms 500 --timeout-ms 8000 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 + "${a[@]}" wait "$sid" --screen-stable-ms 800 --timeout-ms 8000 --json >/dev/null 2>&1 +} +sid="$("${a[@]}" create --json --cols 120 --rows 40 -- \ + bash -lc "cd '$FIX_DIR' && CURSOR_REPRO_PROVIDER=snacks CURSOR_REPRO_POSITION=float CURSOR_REPRO_CMD='python3 $HERE/box.py' CURSOR_REPRO_BOX_LOG='$BOXLOG' NVIM_APPNAME=cursor-toggle-repro XDG_CONFIG_HOME='$FIX_DIR' $NVIM '$WORK/sample.txt'" | + python3 -c "import json,sys;print(json.load(sys.stdin)['result']['sessionId'])")" +"${a[@]}" wait "$sid" --screen-stable-ms 1500 --timeout-ms 15000 --json >/dev/null 2>&1 +"${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 +"${a[@]}" wait "$sid" --text 'synthetic claude' --timeout-ms 15000 --json >/dev/null 2>&1 +"${a[@]}" wait "$sid" --screen-stable-ms 700 --timeout-ms 8000 --json >/dev/null 2>&1 +for _ in $(seq 1 "$CYCLES"); do toggle_cycle "$sid"; done +"${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 +"${a[@]}" type "$sid" ':qa!' --json >/dev/null 2>&1 +"${a[@]}" send-keys "$sid" Enter --json >/dev/null 2>&1 +sleep 0.3 +"${a[@]}" destroy "$sid" --json >/dev/null 2>&1 +winch="$(grep -c SIGWINCH "$BOXLOG" 2>/dev/null || true)" +winch="${winch:-0}" +fin="$(grep -c 'FOCUS_IN' "$BOXLOG" 2>/dev/null || true)" +fin="${fin:-0}" +fout="$(grep -c 'FOCUS_OUT' "$BOXLOG" 2>/dev/null || true)" +fout="${fout:-0}" +echo "Neovim: $($NVIM --version | head -1) cycles=$CYCLES (snacks float, close+recreate toggle)" +echo " SIGWINCH events on toggle: $winch (expect 0 -> NOT a pty resize)" +echo " FOCUS_IN (ESC[I) events: $fin" +echo " FOCUS_OUT (ESC[O) events: $fout (focus churn present on every hide/show)" diff --git a/fixtures/cursor-toggle-repro/box.py b/fixtures/cursor-toggle-repro/box.py new file mode 100755 index 00000000..a37b61e8 --- /dev/null +++ b/fixtures/cursor-toggle-repro/box.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Synthetic Claude-style TUI used to instrument the climbing-cursor bug (#240). + +It does what a modern TUI does on startup: enables focus reporting (DECSET 1004), +bracketed paste (2004) and DSR-style cursor queries, then logs every byte it +receives from the PTY plus every SIGWINCH. The goal is to discover what Neovim +sends to the inner program when the Snacks Claude window is hidden and re-shown +(no real resize was observed), so we can explain why Claude re-renders and the +cursor climbs. + +Log path: $CURSOR_REPRO_BOX_LOG (default /tmp/box.log). +""" +import os +import sys +import time +import signal +import termios +import tty +import select + +LOG = os.environ.get("CURSOR_REPRO_BOX_LOG", "/tmp/box.log") +_seq = 0 +_draws = 0 + + +def _size(): + try: + sz = os.get_terminal_size(sys.stdout.fileno()) + return sz.lines, sz.columns + except OSError: + return (-1, -1) + + +def _log(tag, extra=""): + global _seq + _seq += 1 + rows, cols = _size() + with open(LOG, "a") as fh: + fh.write("%d %s rows=%d cols=%d %s t=%.3f\n" % (_seq, tag, rows, cols, extra, time.time())) + + +def _draw(): + """Redraw a bottom-anchored prompt box using ABSOLUTE positioning. + + Absolute positioning cannot itself drift, so if the visible prompt still + climbs it is Neovim's display of the grid, not our rendering. + """ + global _draws + _draws += 1 + rows, cols = _size() + if rows < 6: + return + bar = "-" * max(1, cols - 1) + out = [] + out.append("\x1b[2J\x1b[H") # clear + cursor home + out.append("synthetic claude-ish TUI draw#%d size=%dx%d\r\n" % (_draws, rows, cols)) + out.append("(scrollback content)\r\n") + out.append("\x1b[%d;1H%s" % (rows - 3, bar)) # top rule of input box + out.append("\x1b[%d;1H> " % (rows - 2)) # prompt line + out.append("\x1b[%d;1H%s" % (rows - 1, bar)) # bottom rule + out.append("\x1b[%d;3H" % (rows - 2)) # park cursor right after "> " + sys.stdout.write("".join(out)) + sys.stdout.flush() + + +def _on_winch(signum, frame): + _log("SIGWINCH") + _draw() + + +def main(): + open(LOG, "w").close() + signal.signal(signal.SIGWINCH, _on_winch) + + fd = sys.stdin.fileno() + old = None + try: + old = termios.tcgetattr(fd) + tty.setraw(fd) + except (termios.error, OSError): + pass + + # Mimic a modern TUI: focus reporting + bracketed paste + hide/show cursor. + sys.stdout.write("\x1b[?1004h\x1b[?2004h") + sys.stdout.flush() + + _log("START") + _draw() + + try: + while True: + r, _, _ = select.select([fd], [], [], 0.3) + if fd in r: + data = os.read(fd, 1024) + if not data: + break + hexs = data.hex() + printable = "".join(chr(b) if 32 <= b < 127 else "." for b in data) + _log("INPUT", "hex=%s repr=%r ascii=%s" % (hexs, data, printable)) + # Focus-in (ESC [ I) -> a modern TUI would re-render here. + if b"\x1b[I" in data: + _log("FOCUS_IN -> redraw") + _draw() + if b"\x1b[O" in data: + _log("FOCUS_OUT") + if data in (b"\x03", b"\x04", b"q"): + break + finally: + sys.stdout.write("\x1b[?1004l\x1b[?2004l") + sys.stdout.flush() + if old is not None: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +if __name__ == "__main__": + main() diff --git a/fixtures/cursor-toggle-repro/float-fix-probe.sh b/fixtures/cursor-toggle-repro/float-fix-probe.sh new file mode 100755 index 00000000..8a6a6a2b --- /dev/null +++ b/fixtures/cursor-toggle-repro/float-fix-probe.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# +# Candidate-fix validation for issue #183. Identical harness to float-repro.sh, +# except the hide/show toggle goes through ah -> :ReproConfigHideToggle, +# which hides the Snacks FLOAT via nvim_win_set_config{hide=true/false} instead +# of letting Snacks close+recreate the window. The triage predicts this keeps the +# window object (libvterm grid + cursor anchor) alive, so Claude's focus-in +# repaint stays aligned and delta stays 0 across toggles. +# +# Compare directly against float-repro.sh: +# float-repro.sh (Snacks close+recreate): delta 0 -> 1 (BUG) +# float-fix-probe.sh (config-hide): expect delta 0 -> 0 (FIXED) +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIX_DIR="$(dirname "$HERE")" +CYCLES="${CYCLES:-5}" +export _ZO_DOCTOR=0 + +if [ -n "${NVIM_BIN:-}" ]; then + NVIM="$NVIM_BIN" +elif command -v mise >/dev/null 2>&1; then + NVIM="mise exec -- nvim" +else NVIM="nvim"; fi + +command -v agent-tty >/dev/null || { + echo "ERROR: agent-tty not found" + exit 1 +} + +WORK="${CLAUDE_JOB_DIR:-/tmp}/tmp/float-fix.$$" +rm -rf "$WORK" +mkdir -p "$WORK" +AGENT_HOME="$WORK/atty-home" +mkdir -p "$AGENT_HOME" +trap 'agent-tty --home "$AGENT_HOME" gc --json >/dev/null 2>&1; rm -rf "$WORK"' EXIT + +python3 - "$WORK/sample.txt" <<'PY' +import sys +open(sys.argv[1], "w").write("\n".join("line %02d" % i for i in range(1, 61)) + "\n") +PY + +a=(agent-tty --home "$AGENT_HOME") + +measure() { + "${a[@]}" snapshot "$1" --json 2>/dev/null | python3 -c ' +import json,sys +r=json.load(sys.stdin)["result"] +rows=[l["row"] for l in r["visibleLines"] if "❯" in l["text"]] +prow=rows[-1] if rows else None +cur=r["cursorRow"] +delta=(prow-cur) if prow is not None else None +print(f"cursorRow={cur} promptRow={prow} delta={delta}") +' +} + +dump() { + "${a[@]}" snapshot "$1" --json 2>/dev/null | python3 -c ' +import json,sys +r=json.load(sys.stdin)["result"] +print("cursorRow=%s cursorCol=%s"%(r.get("cursorRow"),r.get("cursorCol"))) +for l in r["visibleLines"]: + t=l["text"].rstrip() + if t: print("%3d| %s"%(l["row"], t)) +' +} + +# Toggle via the config-hide path (ah). +toggle_cycle() { + local sid="$1" + "${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 + "${a[@]}" send-keys "$sid" 'C-w' 'h' --json >/dev/null 2>&1 + "${a[@]}" wait "$sid" --screen-stable-ms 400 --timeout-ms 8000 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ah' --json >/dev/null 2>&1 # hide (config) + "${a[@]}" wait "$sid" --screen-stable-ms 500 --timeout-ms 8000 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ah' --json >/dev/null 2>&1 # show (config) + "${a[@]}" wait "$sid" --screen-stable-ms 900 --timeout-ms 8000 --json >/dev/null 2>&1 +} + +sid="$("${a[@]}" create --json --cols 120 --rows 40 -- \ + bash -lc "cd '$FIX_DIR' && CURSOR_REPRO_PROVIDER=snacks CURSOR_REPRO_POSITION=float NVIM_APPNAME=cursor-toggle-repro XDG_CONFIG_HOME='$FIX_DIR' $NVIM '$WORK/sample.txt'" | + python3 -c "import json,sys;print(json.load(sys.stdin)['result']['sessionId'])")" + +# shellcheck disable=SC2086 +echo "Neovim: $($NVIM --version | head -1)" +echo "Claude: $(claude --version 2>/dev/null | head -1 || echo '??')" +echo "== provider=snacks (float) + CONFIG-HIDE toggle (candidate fix), cycles=$CYCLES ==" +"${a[@]}" wait "$sid" --screen-stable-ms 1800 --timeout-ms 20000 --json >/dev/null 2>&1 +"${a[@]}" type "$sid" ' ah' --json >/dev/null 2>&1 # first ah -> Snacks creates+shows float +if ! "${a[@]}" wait "$sid" --text 'auto mode' --timeout-ms 40000 --json >/dev/null 2>&1; then + echo " SKIP: Claude did not reach its prompt (not logged in?)." + "${a[@]}" destroy "$sid" --json >/dev/null 2>&1 + exit 0 +fi +"${a[@]}" wait "$sid" --screen-stable-ms 1500 --timeout-ms 20000 --json >/dev/null 2>&1 +echo " baseline: $(measure "$sid")" +for i in $(seq 1 "$CYCLES"); do + toggle_cycle "$sid" + echo " after toggle $i: $(measure "$sid")" +done +"${a[@]}" type "$sid" 'ZZZQ' --json >/dev/null 2>&1 +"${a[@]}" wait "$sid" --screen-stable-ms 800 --timeout-ms 8000 --json >/dev/null 2>&1 +echo " --- final screen (typed marker 'ZZZQ') ---" +dump "$sid" | sed 's/^/ /' +"${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 +"${a[@]}" type "$sid" ':qa!' --json >/dev/null 2>&1 +"${a[@]}" send-keys "$sid" Enter --json >/dev/null 2>&1 +sleep 0.3 +"${a[@]}" destroy "$sid" --json >/dev/null 2>&1 +echo +echo "PASS if delta stays 0 across toggles and 'ZZZQ' lands after ❯ (not on the box border)." diff --git a/fixtures/cursor-toggle-repro/float-repro.sh b/fixtures/cursor-toggle-repro/float-repro.sh new file mode 100755 index 00000000..807a8870 --- /dev/null +++ b/fixtures/cursor-toggle-repro/float-repro.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# +# Reproduction of claudecode.nvim issue #183 — "Input cursor in floating mode +# moves upwards every time I toggle" — driven through a real Neovim TUI inside an +# isolated agent-tty session. +# +# This is the FLOAT-specific sibling of agent-repro.sh (which targets the #240 +# vertical-split case). It launches the Snacks terminal provider with +# `position = "float"` (the exact layout from the bug report), opens the real +# Claude CLI, then hides + re-shows the float CYCLES times. After each cycle it +# prints the delta between Claude's "❯" prompt row and the terminal cursor row: +# +# delta = promptRow - cursorRow +# 0 -> aligned (cursor sits on the ❯ line, correct) +# >0 -> BUG: cursor is delta rows ABOVE the prompt (the "climbing cursor") +# +# #183 reports the drift ACCUMULATES per toggle, so under snacks/float we expect +# delta to grow 0 -> 1 -> 2 -> ... The native provider is run as a control and +# should stay at delta 0. +# +# Finally it focuses the float and types a visible marker ("ZZZQ") so the snapshot +# shows where typed text actually lands relative to the prompt box. +# +# Requirements: agent-tty, python3, nvim (mise), snacks.nvim, a logged-in `claude`. +# +# Usage: +# ./float-repro.sh # 5 hide/show cycles, providers: snacks native +# CYCLES=8 ./float-repro.sh +# PROVIDERS="snacks" ./float-repro.sh +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # .../fixtures/cursor-toggle-repro +FIX_DIR="$(dirname "$HERE")" # .../fixtures +CYCLES="${CYCLES:-5}" +PROVIDERS="${PROVIDERS:-snacks native}" +export _ZO_DOCTOR=0 + +if [ -n "${NVIM_BIN:-}" ]; then + NVIM="$NVIM_BIN" +elif command -v mise >/dev/null 2>&1; then + NVIM="mise exec -- nvim" +else + NVIM="nvim" +fi + +command -v agent-tty >/dev/null || { + echo "ERROR: agent-tty not found" + exit 1 +} +command -v python3 >/dev/null || { + echo "ERROR: python3 not found" + exit 1 +} + +WORK="${CLAUDE_JOB_DIR:-/tmp}/tmp/float-repro.$$" +rm -rf "$WORK" +mkdir -p "$WORK" +AGENT_HOME="$WORK/atty-home" +mkdir -p "$AGENT_HOME" +trap 'agent-tty --home "$AGENT_HOME" gc --json >/dev/null 2>&1; rm -rf "$WORK"' EXIT + +python3 - "$WORK/sample.txt" <<'PY' +import sys +open(sys.argv[1], "w").write("\n".join("line %02d" % i for i in range(1, 61)) + "\n") +PY + +a=(agent-tty --home "$AGENT_HOME") + +# measure -> "cursorRow=.. promptRow=.. delta=.." +measure() { + local sid="$1" + "${a[@]}" snapshot "$sid" --json 2>/dev/null | python3 -c ' +import json,sys +r=json.load(sys.stdin)["result"] +rows=[l["row"] for l in r["visibleLines"] if "❯" in l["text"]] +prow=rows[-1] if rows else None +cur=r["cursorRow"] +delta=(prow-cur) if prow is not None else None +print(f"cursorRow={cur} promptRow={prow} delta={delta}") +' +} + +# dump full visible screen for the final visual proof +dump() { + local sid="$1" + "${a[@]}" snapshot "$sid" --json 2>/dev/null | python3 -c ' +import json,sys +r=json.load(sys.stdin)["result"] +print("cursorRow=%s cursorCol=%s"%(r.get("cursorRow"),r.get("cursorCol"))) +for l in r["visibleLines"]: + t=l["text"].rstrip() + if t: print("%3d| %s"%(l["row"], t)) +' +} + +# escape terminal-insert -> normal, move to editor window, hide float, show float. +toggle_cycle() { + local sid="$1" + "${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 + "${a[@]}" send-keys "$sid" 'C-w' 'h' --json >/dev/null 2>&1 + "${a[@]}" wait "$sid" --screen-stable-ms 400 --timeout-ms 8000 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 # ac -> hide + "${a[@]}" wait "$sid" --screen-stable-ms 500 --timeout-ms 8000 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 # ac -> show + "${a[@]}" wait "$sid" --screen-stable-ms 900 --timeout-ms 8000 --json >/dev/null 2>&1 +} + +launch() { # launch -> echoes sid + local prov="$1" + local env="CURSOR_REPRO_PROVIDER=$prov CURSOR_REPRO_POSITION=float" + env="$env NVIM_APPNAME=cursor-toggle-repro XDG_CONFIG_HOME='$FIX_DIR'" + "${a[@]}" create --json --cols 120 --rows 40 -- \ + bash -lc "cd '$FIX_DIR' && $env $NVIM '$WORK/sample.txt'" | + python3 -c "import json,sys;print(json.load(sys.stdin)['result']['sessionId'])" +} + +# shellcheck disable=SC2086 +echo "Neovim: $($NVIM --version | head -1)" +echo "Claude: $(claude --version 2>/dev/null | head -1 || echo '??')" +echo "position=float width=0.9 height=0.9 cycles=$CYCLES" +echo + +for prov in $PROVIDERS; do + echo "== provider=$prov (float) ==" + sid="$(launch "$prov")" + "${a[@]}" wait "$sid" --screen-stable-ms 1800 --timeout-ms 20000 --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ' ac' --json >/dev/null 2>&1 + if ! "${a[@]}" wait "$sid" --text 'auto mode' --timeout-ms 40000 --json >/dev/null 2>&1; then + echo " SKIP: Claude did not reach its prompt (not logged in?)." + "${a[@]}" destroy "$sid" --json >/dev/null 2>&1 + continue + fi + "${a[@]}" wait "$sid" --screen-stable-ms 1500 --timeout-ms 20000 --json >/dev/null 2>&1 + echo " baseline: $(measure "$sid")" + for i in $(seq 1 "$CYCLES"); do + toggle_cycle "$sid" + echo " after toggle $i: $(measure "$sid")" + done + # Type a visible marker to show where input actually lands (focus is in the float now). + "${a[@]}" type "$sid" 'ZZZQ' --json >/dev/null 2>&1 + "${a[@]}" wait "$sid" --screen-stable-ms 800 --timeout-ms 8000 --json >/dev/null 2>&1 + echo " --- final screen (typed marker 'ZZZQ') ---" + dump "$sid" | sed 's/^/ /' + "${a[@]}" send-keys "$sid" 'C-Backslash' 'C-n' --json >/dev/null 2>&1 + "${a[@]}" type "$sid" ':qa!' --json >/dev/null 2>&1 + "${a[@]}" send-keys "$sid" Enter --json >/dev/null 2>&1 + sleep 0.3 + "${a[@]}" destroy "$sid" --json >/dev/null 2>&1 + echo +done + +echo "Interpretation: snacks/float -> delta climbs (cursor above ❯ = #183 bug);" +echo "native/float -> delta stays 0 (aligned). Same Claude binary, different provider." diff --git a/fixtures/cursor-toggle-repro/init.lua b/fixtures/cursor-toggle-repro/init.lua new file mode 100644 index 00000000..2baf39bb --- /dev/null +++ b/fixtures/cursor-toggle-repro/init.lua @@ -0,0 +1,373 @@ +-- Minimal repro config for issue #240 / #183 — the "climbing cursor" bug. +-- +-- Symptom: with the Snacks terminal provider (LazyVim's default), hiding and then +-- re-showing the Claude side panel leaves the terminal cursor one row ABOVE +-- Claude's "❯" input prompt, so typed text lands on the wrong line and the box +-- corrupts. (#183, on a float, reports the drift accumulating per toggle.) +-- #240: vertical split panel. #183: floating window. Same root cause. +-- +-- This fixture deliberately avoids a plugin manager so it is fast, offline, and +-- easy to reason about (mirrors fixtures/repro). It loads the local +-- claudecode.nvim checkout that owns the fixture and an already-installed +-- snacks.nvim, both via runtimepath. +-- +-- Usage (from repo root): +-- source fixtures/nvim-aliases.sh +-- vv cursor-toggle-repro +-- Then: ac toggles the Claude terminal. Toggle it off, then on, and +-- watch the `>` input prompt climb up one row each cycle. +-- +-- Env knobs: +-- CURSOR_REPRO_CMD command to run in the terminal (default: "claude"). +-- Point at fixtures/cursor-toggle-repro/box.py to get a +-- deterministic, auth-free synthetic Claude-style TUI. +-- CURSOR_REPRO_POSITION "right" (default, split = #240) or "float" (= #183). +-- CURSOR_REPRO_SNACKS_DIR override path to a snacks.nvim checkout. + +local config_dir = vim.fn.stdpath("config") +local repo_root = vim.fn.fnamemodify(vim.env.XDG_CONFIG_HOME or config_dir, ":h") +vim.opt.rtp:prepend(repo_root) + +vim.g.mapleader = " " +vim.g.maplocalleader = "\\" + +-- Quieter, more deterministic editor for reproduction. +vim.o.swapfile = false +vim.o.shada = "" +vim.o.number = true +vim.o.laststatus = 2 +vim.o.cmdheight = 1 + +-- Locate an installed snacks.nvim and put it on the runtimepath. +local function find_snacks() + -- Build the list with table.insert: an explicit nil first element would make + -- ipairs() stop immediately (it terminates at the first nil index). + local candidates = {} + if vim.env.CURSOR_REPRO_SNACKS_DIR and vim.env.CURSOR_REPRO_SNACKS_DIR ~= "" then + table.insert(candidates, vim.env.CURSOR_REPRO_SNACKS_DIR) + end + table.insert(candidates, vim.fn.stdpath("data") .. "/lazy/snacks.nvim") + table.insert(candidates, vim.fn.expand("~/.local/share/nvim/lazy/snacks.nvim")) + table.insert(candidates, vim.fn.expand("~/.local/share/nvim/site/pack/*/start/snacks.nvim")) + for _, path in ipairs(candidates) do + if path and path ~= "" then + for _, hit in ipairs(vim.fn.glob(path, true, true)) do + if vim.fn.isdirectory(hit .. "/lua/snacks") == 1 then + return hit + end + end + end + end + -- Last resort: clone into this fixture's data dir (one-time, needs network). + local dest = vim.fn.stdpath("data") .. "/snacks.nvim" + if vim.fn.isdirectory(dest .. "/lua/snacks") == 0 then + vim.notify("cursor-toggle-repro: cloning snacks.nvim (one-time)...", vim.log.levels.INFO) + vim.fn.system({ "git", "clone", "--filter=blob:none", "https://github.com/folke/snacks.nvim.git", dest }) + end + return dest +end + +local snacks_dir = find_snacks() +vim.opt.rtp:prepend(snacks_dir) + +local ok_snacks = pcall(function() + require("snacks").setup({}) +end) +if not (ok_snacks and _G.Snacks and _G.Snacks.terminal) then + vim.notify("cursor-toggle-repro: snacks.terminal unavailable at " .. snacks_dir, vim.log.levels.ERROR) +end + +local ok, claudecode = pcall(require, "claudecode") +assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode)) + +-- Build snacks_win_opts depending on the requested layout (split vs float). +local position = vim.env.CURSOR_REPRO_POSITION or "right" +local snacks_win_opts = {} +if position == "float" then + snacks_win_opts = { position = "float", width = 0.9, height = 0.9 } +end +-- CURSOR_REPRO_BORDER: override the Snacks window border (default "top" adds a +-- 1-row top border). Set to "none" to test whether the border is what tips the +-- split into the climbing-cursor drift. +if vim.env.CURSOR_REPRO_BORDER and vim.env.CURSOR_REPRO_BORDER ~= "" then + snacks_win_opts.border = vim.env.CURSOR_REPRO_BORDER +end + +claudecode.setup({ + auto_start = false, -- start the server explicitly so toggles are deterministic + -- Keep the screen clean for automated driving; bump to "debug" for triage. + log_level = vim.env.CURSOR_REPRO_LOG_LEVEL or "warn", + -- Honour an explicit synthetic command, else fall back to the real `claude`. + terminal_cmd = vim.env.CURSOR_REPRO_CMD, -- nil => provider default ("claude") + terminal = { + -- LazyVim's effective provider is "snacks"; override to "native" with + -- CURSOR_REPRO_PROVIDER to compare providers when localizing the bug. + provider = vim.env.CURSOR_REPRO_PROVIDER or "snacks", + auto_close = false, + snacks_win_opts = snacks_win_opts, + }, + diff_opts = { + layout = "vertical", + }, +}) + +local function ensure_started() + local ok_start, started_or_err, port_or_err = pcall(function() + return claudecode.start(false) + end) + if not ok_start then + vim.notify("ClaudeCode start crashed: " .. tostring(started_or_err), vim.log.levels.ERROR) + return false + end + if started_or_err then + return true + end + if port_or_err == "Already running" then + return true + end + vim.notify("ClaudeCode failed to start: " .. tostring(port_or_err), vim.log.levels.ERROR) + return false +end + +local terminal = require("claudecode.terminal") + +-- The repro toggle: simple_toggle (== :ClaudeCode), show/hide regardless of focus. +vim.keymap.set({ "n", "t" }, "ac", function() + if ensure_started() then + terminal.simple_toggle({}, nil) + end +end, { desc = "Toggle Claude (simple_toggle)" }) + +vim.keymap.set({ "n", "t" }, "af", function() + if ensure_started() then + terminal.focus_toggle({}, nil) + end +end, { desc = "Focus Claude (focus_toggle)" }) + +-- Make it easy to escape the terminal to drive ex-commands. +vim.keymap.set("t", "", "", { desc = "Exit terminal mode" }) + +-- EXPERIMENT: native-style hide/show of the (snacks-created) Claude buffer. +-- Open once with ac (snacks), then toggle with an. This closes +-- the window with nvim_win_close on hide and recreates a plain vsplit + +-- nvim_win_set_buf on show -- exactly what the native provider does -- but on +-- the SAME terminal buffer snacks spawned. If this does not drift, the cure for +-- the split case is native-style window management, not snacks' open_win. +vim.keymap.set({ "n", "t" }, "an", function() + local bufnr = terminal.get_active_terminal_bufnr() + if not bufnr then + vim.notify("no claude terminal buffer; open it with ac first", vim.log.levels.WARN) + return + end + local win + for _, w in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(w) == bufnr then + win = w + break + end + end + if win then + vim.api.nvim_win_close(win, false) -- native hide: drop the window, keep the buffer+job + else + local width = math.floor(vim.o.columns * 0.30) + vim.cmd("botright " .. width .. "vsplit") + local nw = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(nw, bufnr) + vim.cmd("startinsert") + end +end, { desc = "native-style toggle of claude buffer" }) + +-- EXPERIMENT: config-hide toggle (nvim_win_set_config{hide}) of the Claude +-- window. Keeps the window object alive, so the cursor anchor is preserved. +-- Visually hides FLOATS (use CURSOR_REPRO_POSITION=float); a split stays visible. +vim.keymap.set({ "n", "t" }, "ah", function() + local bufnr = terminal.get_active_terminal_bufnr() + if not bufnr then + return + end + local win + for _, w in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(w) == bufnr then + win = w + break + end + end + if not win then + vim.notify("no claude window", vim.log.levels.WARN) + return + end + if vim.api.nvim_win_get_config(win).hide == true then + vim.api.nvim_win_set_config(win, { hide = false }) + vim.api.nvim_set_current_win(win) + vim.cmd("startinsert") + else + vim.api.nvim_win_set_config(win, { hide = true }) + if vim.api.nvim_get_current_win() == win then + pcall(vim.cmd, "wincmd p") + end + end +end, { desc = "config-hide toggle of claude window" }) + +-- EXPERIMENT: after a normal (drifted) snacks show, re-set the same buffer into +-- its existing window. If this re-anchors (delta back to 0) it is a minimal, +-- snacks-window-PRESERVING fix (keeps snacks' keymaps/styling, unlike a full +-- native recreate). Repro drift with ac, then press ar. +vim.keymap.set({ "n", "t" }, "ar", function() + local bufnr = terminal.get_active_terminal_bufnr() + if not bufnr then + return + end + for _, w in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(w) == bufnr then + vim.api.nvim_win_set_buf(w, bufnr) -- re-set same buffer; may re-anchor the view + return + end + end +end, { desc = "re-set claude buffer into its window" }) + +-- Measurement helper: dump the Claude terminal window's geometry so the climb +-- can be quantified without screen-scraping. Prints cursor row, window topline +-- (first visible buffer line), window height, and total buffer lines. +vim.api.nvim_create_user_command("ReproCursorInfo", function() + local bufnr = terminal.get_active_terminal_bufnr() + if not bufnr then + vim.notify("ReproCursorInfo: no active claude terminal buffer", vim.log.levels.WARN) + return + end + local win + for _, w in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(w) == bufnr then + win = w + break + end + end + if not win then + vim.notify("ReproCursorInfo: terminal buffer not visible in any window", vim.log.levels.WARN) + return + end + local cursor = vim.api.nvim_win_get_cursor(win) + local topline = vim.fn.line("w0", win) + local botline = vim.fn.line("w$", win) + local height = vim.api.nvim_win_get_height(win) + local total = vim.api.nvim_buf_line_count(bufnr) + -- cursor_within_win = how many rows down from the top of the window the cursor + -- sits. This is the number that climbs as you toggle. + local within = cursor[1] - topline + 1 + local line = string.format( + "cursor_line=%d cursor_col=%d topline=%d botline=%d win_height=%d buf_lines=%d cursor_within_win=%d", + cursor[1], + cursor[2], + topline, + botline, + height, + total, + within + ) + -- Append to a log file so automated drivers can read it without screen-scraping + -- and without tripping Neovim's hit-enter prompt. + local log_path = vim.env.CURSOR_REPRO_LOG or (vim.fn.stdpath("cache") .. "/cursor-toggle-repro.log") + local fh = io.open(log_path, "a") + if fh then + fh:write(line .. "\n") + fh:close() + end + vim.api.nvim_echo({ { "ReproCursorInfo: " .. line } }, false, {}) +end, { desc = "Dump claude terminal window geometry" }) + +vim.keymap.set("n", "ai", "ReproCursorInfo", { desc = "Claude terminal geometry" }) + +-- Dump the Claude terminal window's options that could explain a height/anchor +-- difference between providers (winbar, statusline, border, height). +vim.api.nvim_create_user_command("ReproWinDiag", function() + local bufnr = terminal.get_active_terminal_bufnr() + local out = {} + for _, w in ipairs(vim.api.nvim_list_wins()) do + if bufnr and vim.api.nvim_win_get_buf(w) == bufnr then + local cfg = vim.api.nvim_win_get_config(w) + table.insert( + out, + string.format( + "termwin=%d height=%d width=%d winbar=[%s] relative=[%s] zindex=%s", + w, + vim.api.nvim_win_get_height(w), + vim.api.nvim_win_get_width(w), + tostring(vim.wo[w].winbar), + tostring(cfg.relative), + tostring(cfg.zindex) + ) + ) + end + end + local log_path = vim.env.CURSOR_REPRO_LOG or (vim.fn.stdpath("cache") .. "/cursor-toggle-repro.log") + local fh = io.open(log_path, "a") + if fh then + fh:write("WINDIAG " .. (table.concat(out, " | ") == "" and "no termwin" or table.concat(out, " | ")) .. "\n") + fh:close() + end +end, { desc = "Dump claude terminal window options" }) + +-- CANDIDATE FIX PROBE (#183): toggle the Claude FLOAT by HIDING the window via +-- nvim_win_set_config{hide=true/false} instead of closing+recreating it (what +-- Snacks does). This keeps the window OBJECT (and thus libvterm's grid + cursor +-- anchor) alive across the toggle, which the triage predicts keeps Claude's +-- focus-in repaint aligned (delta stays 0). Bound to ah; the float-fix +-- driver uses this instead of ac. +-- +-- We cache the window id because a config-hidden window is still VALID +-- (nvim_win_is_valid==true) but may not be returned by nvim_list_wins(); caching +-- lets us un-hide the exact same window object we hid. +local cached_claude_win = nil +local function locate_claude_float() + local bufnr = terminal.get_active_terminal_bufnr() + if not bufnr then + return nil + end + if cached_claude_win and vim.api.nvim_win_is_valid(cached_claude_win) then + return cached_claude_win + end + for _, w in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(w) and vim.api.nvim_win_get_buf(w) == bufnr then + local cfg = vim.api.nvim_win_get_config(w) + if cfg and cfg.relative and cfg.relative ~= "" then + cached_claude_win = w + return w + end + end + end + return nil +end + +vim.api.nvim_create_user_command("ReproConfigHideToggle", function() + if not ensure_started() then + return + end + local bufnr = terminal.get_active_terminal_bufnr() + -- Nothing open yet: let Snacks create+show the float the first time. + if not bufnr then + terminal.simple_toggle({}, nil) + return + end + local w = locate_claude_float() + if not (w and vim.api.nvim_win_is_valid(w)) then + -- Fall back to Snacks show if we somehow lost the window handle. + terminal.simple_toggle({}, nil) + return + end + local cfg = vim.api.nvim_win_get_config(w) + if cfg.hide then + -- Currently hidden -> show + refocus + insert (mirror what a real toggle does). + vim.api.nvim_win_set_config(w, { hide = false }) + pcall(vim.api.nvim_set_current_win, w) + vim.cmd("startinsert") + else + -- Currently visible -> hide WITHOUT destroying the window. + vim.api.nvim_win_set_config(w, { hide = true }) + end +end, { desc = "Toggle Claude float via win_set_config{hide} (candidate #183 fix)" }) + +vim.keymap.set( + { "n", "t" }, + "ah", + "ReproConfigHideToggle", + { desc = "Config-hide toggle (fix probe)" } +) diff --git a/fixtures/cursor-toggle-repro/sample.txt b/fixtures/cursor-toggle-repro/sample.txt new file mode 100644 index 00000000..9d35e362 --- /dev/null +++ b/fixtures/cursor-toggle-repro/sample.txt @@ -0,0 +1,60 @@ +line 01 +line 02 +line 03 +line 04 +line 05 +line 06 +line 07 +line 08 +line 09 +line 10 +line 11 +line 12 +line 13 +line 14 +line 15 +line 16 +line 17 +line 18 +line 19 +line 20 +line 21 +line 22 +line 23 +line 24 +line 25 +line 26 +line 27 +line 28 +line 29 +line 30 +line 31 +line 32 +line 33 +line 34 +line 35 +line 36 +line 37 +line 38 +line 39 +line 40 +line 41 +line 42 +line 43 +line 44 +line 45 +line 46 +line 47 +line 48 +line 49 +line 50 +line 51 +line 52 +line 53 +line 54 +line 55 +line 56 +line 57 +line 58 +line 59 +line 60 From ce288a4e63623af963532120961fb095f21aee7f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 9 Jun 2026 11:10:27 +0200 Subject: [PATCH 3/3] fix(terminal): keep horizontal Snacks split layout on toggle (codex review) cc_show recreated every split as a vsplit driven by split_side, so a snacks_win_opts.position = "top"/"bottom" terminal silently turned into a left/right vertical split after the first hide/show. Recreate now honors the resolved Snacks position: left/right -> vertical split, top/bottom -> horizontal split sized from snacks_win_opts.height; float and any other position delegate back to Snacks (which owns that geometry). Adds resolve_split_size and unit tests for the horizontal-split and unsupported-position paths. Change-Id: I2087a82e04bd56cc85977051102aa722ba67bdcf Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- lua/claudecode/terminal/snacks.lua | 55 ++++++++++++++++------ tests/unit/terminal/snacks_toggle_spec.lua | 43 ++++++++++++++++- 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 105d039c..1adf12e0 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -120,6 +120,19 @@ local function supports_config_hide() return vim.fn ~= nil and vim.fn.has ~= nil and vim.fn.has("nvim-0.10") == 1 end +-- Resolve a Snacks width/height value to an absolute cell count: a fraction in +-- (0,1) scales `total`; a value >= 1 is taken as absolute; otherwise fall back +-- to `default_frac` of `total`. +local function resolve_split_size(val, total, default_frac) + if type(val) == "number" and val > 0 then + if val < 1 then + return math.max(1, math.floor(total * val)) + end + return math.floor(val) + end + return math.max(1, math.floor(total * default_frac)) +end + local function start_insert_if_terminal(term) if term.buf @@ -249,14 +262,16 @@ local function cc_show(term, focus, config) return true end - -- Window is fully gone. A FLOAT (one we closed on pre-0.10, or that was closed - -- externally) must be recreated by Snacks so it stays a float -- recreating it - -- as a split would be wrong. Splits use the native vsplit path, which preserves - -- Claude's cursor anchor. - local want_float = (term._cc and term._cc.kind == "float") - or (config and config.snacks_win_opts and config.snacks_win_opts.position == "float") - if want_float and term._cc and term._cc.orig_show then - logger.debug("terminal", "Snacks show: re-creating float via Snacks") + -- Window is fully gone. Recreate it honoring the configured Snacks position: + -- * float / any non-split position -> let Snacks re-create it (it owns the + -- geometry); recreating it as a plain split would change its kind. + -- * left/right -> vertical split, top/bottom -> horizontal split. Recreated + -- natively (the drift-free path), sized from the resolved Snacks opts. + local win_opts = (config and config.snacks_win_opts) or {} + local position = win_opts.position or (config and config.split_side) or "right" + local is_native_split = position == "left" or position == "right" or position == "top" or position == "bottom" + if (not is_native_split or (term._cc and term._cc.kind == "float")) and term._cc and term._cc.orig_show then + logger.debug("terminal", "Snacks show: re-creating via Snacks (position=" .. tostring(position) .. ")") term._cc.kind = nil term._cc.orig_show(term) if focus and term.win and vim.api.nvim_win_is_valid(term.win) then @@ -266,18 +281,28 @@ local function cc_show(term, focus, config) return true end - logger.debug("terminal", "Snacks show: re-creating split (native vsplit)") local original_win = vim.api.nvim_get_current_win() - local pct = (config and config.split_width_percentage) or 0.30 - local width = math.floor(vim.o.columns * pct) - local placement = ((config and config.split_side) == "left") and "topleft " or "botright " - vim.cmd(placement .. width .. "vsplit") - local new_win = vim.api.nvim_get_current_win() + local horizontal = position == "top" or position == "bottom" + local lead = (position == "top" or position == "left") and "topleft " or "botright " + local new_win + if horizontal then + local height = resolve_split_size(win_opts.height, vim.o.lines, 0.30) + logger.debug("terminal", "Snacks show: re-creating " .. position .. " split (native, h=" .. height .. ")") + vim.cmd(lead .. height .. "split") + new_win = vim.api.nvim_get_current_win() + else + local width = resolve_split_size(win_opts.width, vim.o.columns, (config and config.split_width_percentage) or 0.30) + logger.debug("terminal", "Snacks show: re-creating " .. position .. " split (native, w=" .. width .. ")") + vim.cmd(lead .. width .. "vsplit") + new_win = vim.api.nvim_get_current_win() + end -- Set term.win before nvim_win_set_buf so Snacks' fixbuf BufWinEnter autocmd -- (if still registered) sees a valid window and does not self-delete. term.win = new_win vim.api.nvim_win_set_buf(new_win, term.buf) - vim.api.nvim_win_set_height(new_win, vim.o.lines) -- full height, like native + if not horizontal then + vim.api.nvim_win_set_height(new_win, vim.o.lines) -- full height for vertical splits, like native + end term.closed = false reapply_snacks_window_state(term, new_win) if focus then diff --git a/tests/unit/terminal/snacks_toggle_spec.lua b/tests/unit/terminal/snacks_toggle_spec.lua index 2bf246ae..a318e86d 100644 --- a/tests/unit/terminal/snacks_toggle_spec.lua +++ b/tests/unit/terminal/snacks_toggle_spec.lua @@ -19,6 +19,7 @@ describe("claudecode.terminal.snacks hide/show/toggle", function() local next_buf_id local mock_snacks local open_show_spy + local last_split_cmd local function make_window(buf, opts) local id = next_win_id @@ -74,6 +75,7 @@ describe("claudecode.terminal.snacks hide/show/toggle", function() current_win = 0 next_win_id = 1000 next_buf_id = 1 + last_split_cmd = nil open_show_spy = spy.new(function(self) -- Snacks re-creates the window from the original opts (e.g. a float). @@ -107,11 +109,13 @@ describe("claudecode.terminal.snacks hide/show/toggle", function() }, cmd = function(c) c = tostring(c) - if c:find("vsplit") then + -- matches both "vsplit" (vertical) and "split" (horizontal); record only + -- split commands so a later startinsert/wincmd does not clobber it + if c:find("split") then + last_split_cmd = c local new_id = make_window(nil, { relative = "" }) current_win = new_id end - -- startinsert / wincmd p / noh: no-ops for these tests end, tbl_deep_extend = function(_, ...) local res = {} @@ -317,4 +321,39 @@ describe("claudecode.terminal.snacks hide/show/toggle", function() assert.are.equal(win, term.win) assert.is_true(windows[win].valid) end) + + it("recreates a top/bottom position as a horizontal split, not a vsplit", function() + local function bottom_config() + return { + cwd = "/w", + split_side = "right", + split_width_percentage = 0.3, + auto_close = false, + snacks_win_opts = { position = "bottom", height = 0.3 }, + } + end + snacks_provider.open("claude", {}, bottom_config(), true) + snacks_provider.simple_toggle("claude", {}, bottom_config()) -- hide (close) + last_split_cmd = nil + snacks_provider.simple_toggle("claude", {}, bottom_config()) -- show (recreate) + assert.is_truthy(last_split_cmd and last_split_cmd:find("split")) + assert.is_nil(last_split_cmd:find("vsplit")) -- horizontal split, not vertical + assert.spy(open_show_spy).was_not_called() -- native recreate, not Snacks' + end) + + it("recreates an unsupported position (e.g. current) via Snacks, not a raw split", function() + local function current_config() + return { + cwd = "/w", + split_side = "right", + split_width_percentage = 0.3, + auto_close = false, + snacks_win_opts = { position = "current" }, + } + end + snacks_provider.open("claude", {}, current_config(), true) + snacks_provider.simple_toggle("claude", {}, current_config()) -- hide (close) + snacks_provider.simple_toggle("claude", {}, current_config()) -- show + assert.spy(open_show_spy).was_called() -- delegated to Snacks + end) end)