From 7f9347d2dcb1cc8bd93d7b82b6ae8bac046bcf39 Mon Sep 17 00:00:00 2001 From: xieanfeng Date: Tue, 31 Mar 2026 17:33:18 +0800 Subject: [PATCH 1/2] feat(terminal): add auto_insert option to preserve scroll position When switching back to the Claude Code terminal from the editor, the terminal auto-enters insert mode and scrolls to the bottom, losing the user's reading position. Add `terminal.auto_insert` config option (default: true) that when set to false, keeps the terminal in normal mode and preserves the scroll position. Closes #232 Signed-off-by: Thomas Kosiewski --- lua/claudecode/diff.lua | 34 ++++++++++++++++++------------ lua/claudecode/terminal.lua | 8 +++++++ lua/claudecode/terminal/native.lua | 16 ++++++++++---- lua/claudecode/terminal/snacks.lua | 26 ++++++++++++++++------- tests/unit/terminal_spec.lua | 22 +++++++++++++++++++ 5 files changed, 81 insertions(+), 25 deletions(-) diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 400dc177..c75cf5de 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -392,17 +392,20 @@ local function display_terminal_in_new_tab() apply_window_options(terminal_win, terminal_options) -- Set up autocmd to enter terminal mode when focusing this terminal window - vim.api.nvim_create_autocmd("BufEnter", { - buffer = terminal_bufnr, - group = get_autocmd_group(), - callback = function() - -- Only enter insert mode if we're in a terminal buffer and in normal mode - if vim.bo.buftype == "terminal" and vim.fn.mode() == "n" then - vim.cmd("startinsert") - end - end, - desc = "Auto-enter terminal mode when focusing Claude Code terminal", - }) + local terminal_auto_insert = not config or not config.terminal or config.terminal.auto_insert ~= false + if terminal_auto_insert then + vim.api.nvim_create_autocmd("BufEnter", { + buffer = terminal_bufnr, + group = get_autocmd_group(), + callback = function() + -- Only enter insert mode if we're in a terminal buffer and in normal mode + if vim.bo.buftype == "terminal" and vim.fn.mode() == "n" then + vim.cmd("startinsert") + end + end, + desc = "Auto-enter terminal mode when focusing Claude Code terminal", + }) + end -- Size the terminal for the diff (unless the user opted out via auto_resize_terminal). resize_terminal_for_diff(terminal_win, "diff") @@ -709,17 +712,22 @@ local function setup_new_buffer( vim.b[new_buf].claudecode_diff_target_win = target_win_for_meta if config and config.diff_opts and config.diff_opts.keep_terminal_focus then + local auto_insert = not config.terminal or config.terminal.auto_insert ~= false vim.schedule(function() if terminal_win_in_new_tab and vim.api.nvim_win_is_valid(terminal_win_in_new_tab) then vim.api.nvim_set_current_win(terminal_win_in_new_tab) - vim.cmd("startinsert") + if auto_insert then + vim.cmd("startinsert") + end return end local terminal_win = find_claudecode_terminal_window() if terminal_win then vim.api.nvim_set_current_win(terminal_win) - vim.cmd("startinsert") + if auto_insert then + vim.cmd("startinsert") + end end end) end diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 5e693de2..15be75a9 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -18,6 +18,7 @@ local defaults = { external_terminal_cmd = nil, }, auto_close = true, + auto_insert = true, env = {}, snacks_win_opts = {}, fix_streamed_paste = "auto", -- work around Neovim <0.12.2 paste fragmentation (#161): true|false|"auto" @@ -270,6 +271,7 @@ local function build_config(opts_override) split_side = effective_config.split_side, split_width_percentage = effective_config.split_width_percentage, auto_close = effective_config.auto_close, + auto_insert = effective_config.auto_insert, snacks_win_opts = effective_config.snacks_win_opts, cwd = resolved_cwd, } @@ -519,6 +521,12 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) else vim.notify("claudecode.terminal.setup: Invalid value for auto_close: " .. tostring(v), vim.log.levels.WARN) end + elseif k == "auto_insert" then + if type(v) == "boolean" then + defaults.auto_insert = v + else + vim.notify("claudecode.terminal.setup: Invalid value for auto_insert: " .. tostring(v), vim.log.levels.WARN) + end elseif k == "snacks_win_opts" then if type(v) == "table" then defaults.snacks_win_opts = v diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 9a22901e..1382bca2 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -54,7 +54,9 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) if focus then -- Focus existing terminal: switch to terminal window and enter insert mode vim.api.nvim_set_current_win(winid) - vim.cmd("startinsert") + if effective_config.auto_insert ~= false then + vim.cmd("startinsert") + end end -- If focus=false, preserve user context by staying in current window return true @@ -135,7 +137,9 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) if focus then -- Focus the terminal: switch to terminal window and enter insert mode vim.api.nvim_set_current_win(winid) - vim.cmd("startinsert") + if effective_config.auto_insert ~= false then + vim.cmd("startinsert") + end else -- Preserve user context: return to the window they were in before terminal creation vim.api.nvim_set_current_win(original_win) @@ -162,7 +166,9 @@ end local function focus_terminal() if is_valid() then vim.api.nvim_set_current_win(winid) - vim.cmd("startinsert") + if config.auto_insert ~= false then + vim.cmd("startinsert") + end end end @@ -235,7 +241,9 @@ local function show_hidden_terminal(effective_config, focus) if focus then -- Focus the terminal: switch to terminal window and enter insert mode vim.api.nvim_set_current_win(winid) - vim.cmd("startinsert") + if effective_config.auto_insert ~= false then + vim.cmd("startinsert") + end else -- Preserve user context: return to the window they were in before showing terminal vim.api.nvim_set_current_win(original_win) diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 1adf12e0..6bdf7ccd 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -48,11 +48,12 @@ end ---@return snacks.terminal.Opts opts Snacks terminal options with start_insert/auto_insert controlled by focus parameter local function build_opts(config, env_table, focus) focus = utils.normalize_focus(focus) + local should_insert = focus and config.auto_insert ~= false return { env = env_table, cwd = config.cwd, - start_insert = focus, - auto_insert = focus, + start_insert = should_insert, + auto_insert = should_insert, auto_close = false, win = vim.tbl_deep_extend("force", { position = config.split_side, @@ -147,6 +148,15 @@ local function start_insert_if_terminal(term) end end +-- Enter insert mode unless the user opted out via terminal.auto_insert = false +-- (issue #232). When disabled, re-focusing the terminal window keeps Normal mode +-- and preserves the scroll position instead of jumping to the prompt. +local function maybe_start_insert(term, config) + if not config or config.auto_insert ~= false then + start_insert_if_terminal(term) + 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 @@ -248,7 +258,7 @@ local function cc_show(term, focus, config) set_backdrop_hidden(term, false) if focus then vim.api.nvim_set_current_win(win) - start_insert_if_terminal(term) + maybe_start_insert(term, config) end return true end @@ -257,7 +267,7 @@ local function cc_show(term, focus, config) if cc_is_visible(term) then if focus then vim.api.nvim_set_current_win(term.win) - start_insert_if_terminal(term) + maybe_start_insert(term, config) end return true end @@ -276,7 +286,7 @@ local function cc_show(term, focus, config) 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) + maybe_start_insert(term, config) end return true end @@ -306,7 +316,7 @@ local function cc_show(term, focus, config) term.closed = false reapply_snacks_window_state(term, new_win) if focus then - start_insert_if_terminal(term) + maybe_start_insert(term, config) elseif vim.api.nvim_win_is_valid(original_win) then vim.api.nvim_set_current_win(original_win) end @@ -369,7 +379,7 @@ function M.open(cmd_string, env_table, config, focus) if terminal and terminal:buf_valid() then -- 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). + -- Claude's cursor -- #240/#183). cc_show honors config.auto_insert (#232). cc_show(terminal, focus, config) return end @@ -473,7 +483,7 @@ function M.focus_toggle(cmd_string, env_table, config) -- Visible but not focused -> focus it. logger.debug("terminal", "Focus toggle: focusing terminal") vim.api.nvim_set_current_win(terminal.win) - start_insert_if_terminal(terminal) + maybe_start_insert(terminal, config) end else logger.debug("terminal", "Focus toggle: creating new terminal") diff --git a/tests/unit/terminal_spec.lua b/tests/unit/terminal_spec.lua index 73945815..0c771cc0 100644 --- a/tests/unit/terminal_spec.lua +++ b/tests/unit/terminal_spec.lua @@ -435,6 +435,28 @@ describe("claudecode.terminal (wrapper for Snacks.nvim)", function() ) end) + it("should store valid auto_insert setting", function() + terminal_wrapper.setup({ auto_insert = false }) + terminal_wrapper.open() + local config_arg = mock_snacks_provider.open:get_call(1).refs[3] + assert.are.equal(false, config_arg.auto_insert) + end) + + it("should default auto_insert to true", function() + terminal_wrapper.setup({}) + terminal_wrapper.open() + local config_arg = mock_snacks_provider.open:get_call(1).refs[3] + assert.are.equal(true, config_arg.auto_insert) + end) + + it("should ignore invalid auto_insert and use default", function() + terminal_wrapper.setup({ auto_insert = "invalid" }) + terminal_wrapper.open() + local config_arg = mock_snacks_provider.open:get_call(1).refs[3] + assert.are.equal(true, config_arg.auto_insert) + vim.notify:was_called_with(spy.matching.string.match("Invalid value for auto_insert"), vim.log.levels.WARN) + end) + it("should use defaults if user_term_config is not a table and notify", function() terminal_wrapper.setup("not_a_table") terminal_wrapper.open() From 02afddb08318d5b7a588c216e9c9ce5d6bf4f4ff Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 23 Jun 2026 09:51:09 +0200 Subject: [PATCH 2/2] docs(terminal): document auto_insert + add #232 repro fixture Supporting changes for the terminal.auto_insert feature (#232, #145): - README: document terminal.auto_insert (note that false also opens the terminal in Normal mode, since it gates start-insert too) - CHANGELOG: add [Unreleased] entry - types.lua: annotate ClaudeCodeTerminalConfig.auto_insert - fixtures/issue-232 + scripts/repro_issue_232.lua: env-selectable reproduction (snacks reproduces, native is the baseline) plus a headless regression harness that asserts the auto-insert BufEnter autocmd is absent when auto_insert=false Change-Id: I155f9896ba32a8522344e07b016e9e0854c41d9b Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CHANGELOG.md | 1 + README.md | 5 + fixtures/issue-232/README.md | 80 +++++++ fixtures/issue-232/example/notes.md | 14 ++ fixtures/issue-232/fake-claude.sh | 14 ++ fixtures/issue-232/init.lua | 16 ++ fixtures/issue-232/lua/config/lazy.lua | 70 ++++++ fixtures/issue-232/lua/plugins/claudecode.lua | 32 +++ fixtures/issue-232/lua/plugins/snacks.lua | 12 + lua/claudecode/types.lua | 1 + scripts/repro_issue_232.lua | 206 ++++++++++++++++++ 11 files changed, 451 insertions(+) create mode 100644 fixtures/issue-232/README.md create mode 100644 fixtures/issue-232/example/notes.md create mode 100755 fixtures/issue-232/fake-claude.sh create mode 100644 fixtures/issue-232/init.lua create mode 100644 fixtures/issue-232/lua/config/lazy.lua create mode 100644 fixtures/issue-232/lua/plugins/claudecode.lua create mode 100644 fixtures/issue-232/lua/plugins/snacks.lua create mode 100644 scripts/repro_issue_232.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 40bbad95..27032f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - `User ClaudeCodeSendComplete` autocmd, fired once per file when a send is accepted while Claude is connected, with `data = { file_path, start_line, end_line, context }` (lines 0-indexed). Lets you run arbitrary post-send logic — in particular, focus a Claude session running outside Neovim (`provider = "none"`/`"external"`), e.g. via `tmux select-pane`, which `focus_after_send` cannot do. ([#228](https://github.com/coder/claudecode.nvim/issues/228)) - `:ClaudeCodeCloseAllDiffs` command to close pending Claude diffs at once (e.g. proposals orphaned by resolving them via Claude remote control). Diffs you have already accepted but whose file has not been written yet are left intact so saved edits are never discarded. ([#248](https://github.com/coder/claudecode.nvim/issues/248)) - `:ClaudeCodeSendText {text}` command (and `require("claudecode.terminal").send_to_terminal(text, opts)` function) to send arbitrary text to the open Claude terminal as if typed at the prompt, submitting it by default. `:ClaudeCodeSendText!` inserts the text without submitting. Handy for scripting and keymaps; multi-line text is sent via bracketed paste. Works with the in-editor `native`/`snacks` providers only — `external`/`none` run Claude outside Neovim, where there is no pane to write to. ([#197](https://github.com/coder/claudecode.nvim/issues/197)) +- `terminal.auto_insert` option (default `true`) controlling whether the Claude terminal auto-enters insert/terminal mode when its window gains focus. With the default Snacks provider, switching back into the terminal window (e.g. `l`) previously re-entered terminal mode and jumped to the bottom prompt, discarding your Normal-mode scroll/reading position; set `auto_insert = false` to stay in Normal mode and preserve the scroll position (press `i` to type). Applies to the `native` and `snacks` providers and the new-tab diff terminal. ([#232](https://github.com/coder/claudecode.nvim/issues/232), [#145](https://github.com/coder/claudecode.nvim/issues/145)) ### Bug Fixes diff --git a/README.md b/README.md index f5ebd6f4..ff161755 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,11 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). diff_split_width_percentage = nil, -- e.g. 0.20 to give diffs more room provider = "auto", -- "auto", "snacks", "native", "external", "none", or custom provider table auto_close = true, + -- Auto-enter insert/terminal mode whenever the Claude terminal window gains + -- focus. Set to false to stay in Normal mode and preserve your scroll position + -- when switching back to the terminal (e.g. via l); press `i` to type. + -- Note: false also opens the terminal in Normal mode (it gates start-insert too). + auto_insert = true, snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below -- Work around a Neovim core bug (< 0.12.2) that fragments large pastes into -- the terminal, making Cmd+V appear to truncate ([#161]). true | false | "auto" diff --git a/fixtures/issue-232/README.md b/fixtures/issue-232/README.md new file mode 100644 index 00000000..cb0d16c7 --- /dev/null +++ b/fixtures/issue-232/README.md @@ -0,0 +1,80 @@ +# Fixture: issue #232 — terminal jumps to the bottom / re-enters insert mode on re-focus + +> [FEATURE] Terminal window should restore scroll position when switching back +> from editor window — https://github.com/coder/claudecode.nvim/issues/232 +> +> Duplicate of #145; an implementation already exists as **PR #233** +> (`terminal.auto_insert`). + +## Symptom + +With the **Snacks** terminal provider (the default when `snacks.nvim` is +installed), reading Claude's output in Normal mode and then switching focus back +into the terminal window (e.g. `l` / ``) throws you back into +terminal-mode at the bottom prompt, discarding your scroll/reading position. + +## Root cause + +`build_opts` in `lua/claudecode/terminal/snacks.lua` passes Snacks +`auto_insert = focus`. Snacks' `auto_insert` registers a **buffer-local +`BufEnter` autocmd that runs `startinsert` on every entry** into the terminal +buffer, so re-focusing the window forces terminal-mode and snaps to the prompt. +The **native** provider registers no such autocmd, so it does NOT exhibit this +on plain window navigation (it only force-inserts on `:ClaudeCodeFocus`/toggle). + +`snacks_win_opts` cannot fix this from user config: it is merged only into the +Snacks `win` table, whereas `auto_insert`/`start_insert` are top-level +`snacks.terminal.Opts` fields (this is exactly what the #145 reporter tried). + +## Run it + +```sh +source fixtures/nvim-aliases.sh + +# Reproduce (Snacks, default): +CLAUDECODE_PROVIDER=snacks vv issue-232 + +# Baseline (native — does NOT reproduce): +CLAUDECODE_PROVIDER=native vv issue-232 +``` + +The fixture uses `fake-claude.sh` (200 lines of output + a live `cat` prompt) in +place of the real Claude CLI, so no auth/network is needed. + +### Manual steps (matches the issue report) + +1. Press `r` (or `:Repro`) to lay out: sample file (left) + Claude + terminal (right, focused). +2. In the terminal press `` to enter Normal mode, then `gg` to scroll + to the top (you should see `claude output line 001`). +3. Press `` to jump to the editor window. +4. Press `` to jump back to the terminal. + +Every window's statusline shows `MODE=%{mode()}` so you can see the mode flip. + +- **Snacks (bug):** after step 4 the statusline shows `MODE=t`, `-- TERMINAL --` + appears, and the view jumps to `claude output line 200` / the `>` prompt. +- **Native (baseline):** after step 4 the statusline stays `MODE=n` and the view + stays at `claude output line 001`. + +## Deterministic / headless check + +`scripts/repro_issue_232.lua` asserts the mechanism (the `BufEnter`→`startinsert` +autocmd) without a UI: + +```sh +CLAUDECODE_PROVIDER=snacks nvim --headless -u NONE -l scripts/repro_issue_232.lua # exit 1 (reproduced) +CLAUDECODE_PROVIDER=native nvim --headless -u NONE -l scripts/repro_issue_232.lua # exit 0 (baseline) +CLAUDECODE_PROVIDER=snacks CLAUDECODE_AUTO_INSERT=false nvim --headless -u NONE -l scripts/repro_issue_232.lua # exit 0 (fixed by PR #233) +``` + +(The visible mode flip needs an attached UI; in headless `-l` mode `startinsert` +is deferred and never applied, so the script keys its verdict off the autocmd +probe, not `mode()`.) + +## Workarounds available today (before PR #233 lands) + +- Use `terminal = { provider = "native" }` if you rely on `l`-style window + navigation (preserves scroll/Normal mode on re-focus). +- Or copy the snacks provider into a custom provider and drop the `startinsert` + / `auto_insert` calls (maintainer's suggestion on #145). diff --git a/fixtures/issue-232/example/notes.md b/fixtures/issue-232/example/notes.md new file mode 100644 index 00000000..579f7d9d --- /dev/null +++ b/fixtures/issue-232/example/notes.md @@ -0,0 +1,14 @@ +# Sample editor buffer (issue #232) + +This file stands in for "my code" in the left window. The reproduction workflow: + +1. Read Claude's output in the RIGHT (terminal) window in Normal mode. +2. Jump to THIS window with `` to check some code. +3. Jump back to the terminal with `` to keep reading. + +With the snacks provider, step 3 throws you back into terminal mode at the +bottom prompt -- the scroll position from step 1 is lost. + +(Line A) the quick brown fox jumps over the lazy dog +(Line B) the quick brown fox jumps over the lazy dog +(Line C) the quick brown fox jumps over the lazy dog diff --git a/fixtures/issue-232/fake-claude.sh b/fixtures/issue-232/fake-claude.sh new file mode 100755 index 00000000..95352275 --- /dev/null +++ b/fixtures/issue-232/fake-claude.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Fake "Claude" CLI used to reproduce issue #232 without a real Claude session. +# +# It prints a long block of numbered output (so there is genuine scrollback to +# lose) and then drops into `cat`, which keeps the job alive with a prompt-like +# bottom line. That is all the reproduction needs: a live terminal buffer whose +# cursor/PTY lives at the BOTTOM, while the user reads near the TOP in Normal +# mode. +for i in $(seq 1 200); do + printf 'claude output line %03d ........................................\n' "$i" +done +printf '\n--- END OF OUTPUT (scroll UP to read from line 001) ---\n' +printf '> ' +exec cat diff --git a/fixtures/issue-232/init.lua b/fixtures/issue-232/init.lua new file mode 100644 index 00000000..b9eacca6 --- /dev/null +++ b/fixtures/issue-232/init.lua @@ -0,0 +1,16 @@ +-- Fixture for issue #232: +-- "[FEATURE] Terminal window should restore scroll position when switching +-- back from editor window" +-- +-- Repro of the underlying behavior: with the Snacks terminal provider (the +-- default when snacks.nvim is installed), switching focus BACK into the Claude +-- terminal window re-enters terminal/insert mode and the view jumps to the +-- bottom (the prompt), discarding the Normal-mode scroll position the user was +-- reading at. +-- +-- Provider is selectable so you can A/B the two code paths with ONE fixture: +-- CLAUDECODE_PROVIDER=snacks vv issue-232 (default; reproduces the bug) +-- CLAUDECODE_PROVIDER=native vv issue-232 (does NOT reproduce -> baseline) +-- +-- See README.md in this directory for the exact manual steps. +require("config.lazy") diff --git a/fixtures/issue-232/lua/config/lazy.lua b/fixtures/issue-232/lua/config/lazy.lua new file mode 100644 index 00000000..34fa4c4e --- /dev/null +++ b/fixtures/issue-232/lua/config/lazy.lua @@ -0,0 +1,70 @@ +-- Bootstrap lazy.nvim +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +if not (vim.uv or vim.loop).fs_stat(lazypath) then + local lazyrepo = "https://github.com/folke/lazy.nvim.git" + local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) + if vim.v.shell_error ~= 0 then + vim.api.nvim_echo({ + { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, + { out, "WarningMsg" }, + { "\nPress any key to exit..." }, + }, true, {}) + vim.fn.getchar() + os.exit(1) + end +end +vim.opt.rtp:prepend(lazypath) + +vim.g.mapleader = " " +vim.g.maplocalleader = "\\" + +-- Resolve the claudecode.nvim checkout that owns this fixture (XDG_CONFIG_HOME is +-- the `fixtures/` dir under the `vv` launcher, so its parent is the repo root). +-- Works from a normal checkout or a git worktree. +local repo_root = vim.fn.fnamemodify(vim.env.XDG_CONFIG_HOME or vim.fn.getcwd(), ":h") +vim.g.claudecode_dev_dir = repo_root + +require("lazy").setup({ + spec = { + { import = "plugins" }, + }, + install = { colorscheme = { "habamax" } }, + checker = { enabled = false }, +}) + +-- Window navigation like the issue reporter's setup (Ctrl-h / Ctrl-l). The +-- terminal-mode maps leave terminal mode FIRST, then move -- so any jump back to +-- the bottom is caused by the provider re-entering insert mode, not by the maps. +vim.keymap.set("n", "", "h", { silent = true, desc = "Window left" }) +vim.keymap.set("n", "", "l", { silent = true, desc = "Window right" }) +vim.keymap.set("t", "", [[h]], { silent = true, desc = "Window left (from terminal)" }) +vim.keymap.set("t", "", [[l]], { silent = true, desc = "Window right (from terminal)" }) +vim.keymap.set("t", "", [[]], { silent = true, desc = "Exit terminal mode (double esc)" }) + +-- Make the current mode + window visible in EVERY window's statusline so a +-- terminal snapshot reveals whether we landed in Normal ('n') or Terminal ('t') +-- mode after switching back. +vim.o.laststatus = 2 +vim.o.statusline = " MODE=%{mode()} win=%{winnr()} %f " + +-- One-shot layout helper so the reproduction is deterministic and scriptable: +-- 1. edit the sample file in the left window +-- 2. open the Claude terminal (focused) in a right split +-- After calling this you are IN the terminal, in terminal mode, at the bottom. +_G.repro_setup = function() + vim.cmd("edit " .. vim.fn.fnameescape(vim.g.claudecode_dev_dir .. "/fixtures/issue-232/example/notes.md")) + require("claudecode.terminal").simple_toggle({}, nil) +end +vim.api.nvim_create_user_command("Repro", _G.repro_setup, { desc = "Set up issue #232 reproduction layout" }) + +vim.schedule(function() + local provider = vim.env.CLAUDECODE_PROVIDER or "snacks" + vim.notify( + ("[issue-232] provider=%s -- run :Repro (or r), then in the terminal: , gg, , "):format( + provider + ), + vim.log.levels.INFO + ) +end) + +vim.keymap.set("n", "r", "Repro", { desc = "issue-232 repro layout" }) diff --git a/fixtures/issue-232/lua/plugins/claudecode.lua b/fixtures/issue-232/lua/plugins/claudecode.lua new file mode 100644 index 00000000..1f81ba39 --- /dev/null +++ b/fixtures/issue-232/lua/plugins/claudecode.lua @@ -0,0 +1,32 @@ +-- Load the local claudecode.nvim checkout (resolved in lua/config/lazy.lua). +-- +-- Provider and the terminal command are env-controlled so the SAME fixture +-- reproduces the bug (snacks) and shows the baseline (native): +-- CLAUDECODE_PROVIDER=snacks|native (default: snacks) +-- +-- terminal_cmd points at fake-claude.sh -- a long-output, stays-alive stand-in +-- for the real Claude CLI, so the repro needs no network and no auth. +local provider = vim.env.CLAUDECODE_PROVIDER or "snacks" +local fake_claude = vim.g.claudecode_dev_dir .. "/fixtures/issue-232/fake-claude.sh" + +return { + "coder/claudecode.nvim", + dir = vim.g.claudecode_dev_dir, + dependencies = { "folke/snacks.nvim" }, + keys = { + { "ac", "ClaudeCode", desc = "Toggle Claude" }, + { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, + }, + ---@type PartialClaudeCodeConfig + opts = { + auto_start = false, -- no server/port/lockfile needed for this UI repro + log_level = "debug", + terminal_cmd = fake_claude, + terminal = { + provider = provider, + split_side = "right", + split_width_percentage = 0.45, + auto_close = false, + }, + }, +} diff --git a/fixtures/issue-232/lua/plugins/snacks.lua b/fixtures/issue-232/lua/plugins/snacks.lua new file mode 100644 index 00000000..5133f1c4 --- /dev/null +++ b/fixtures/issue-232/lua/plugins/snacks.lua @@ -0,0 +1,12 @@ +-- snacks.nvim is the default terminal backend for claudecode.nvim ("auto" +-- prefers it when installed). Its terminal is what re-enters insert mode on +-- focus (auto_insert), which is the behavior issue #232 is about. +return { + "folke/snacks.nvim", + priority = 1000, + lazy = false, + ---@type snacks.Config + opts = { + -- Nothing special; we only need Snacks.terminal available. + }, +} diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index c948ccde..17c5620a 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -95,6 +95,7 @@ ---@field terminal_cmd string? ---@field provider_opts ClaudeCodeTerminalProviderOptions? ---@field auto_close boolean +---@field auto_insert boolean -- auto-enter insert/terminal mode when the Claude terminal gains focus (#232); false keeps Normal mode + scroll position ---@field env table ---@field snacks_win_opts snacks.win.Config ---@field cwd string|nil -- static working directory for Claude terminal diff --git a/scripts/repro_issue_232.lua b/scripts/repro_issue_232.lua new file mode 100644 index 00000000..4a431ddb --- /dev/null +++ b/scripts/repro_issue_232.lua @@ -0,0 +1,206 @@ +-- Reproduction / verification for issue #232: +-- "[FEATURE] Terminal window should restore scroll position when switching +-- back from editor window" +-- https://github.com/coder/claudecode.nvim/issues/232 +-- +-- Behavior under test: when focus returns to the Claude terminal window (as if +-- via `l`), does the plugin force the terminal back into terminal/insert +-- mode? With the Snacks provider it does -- Snacks' `auto_insert` registers a +-- buffer-local BufEnter autocmd that runs `startinsert` on every entry, which +-- snaps the view to the bottom prompt and discards the user's Normal-mode scroll +-- position. The native provider registers no such autocmd, so it stays in Normal +-- mode (the behavior the reporter wants). +-- +-- This script drives the REAL terminal provider code (no Claude CLI, no network; +-- the terminal runs a trivial `cat`). It: +-- 1. opens the Claude terminal (focused), +-- 2. drops to Normal mode and scrolls to the TOP, +-- 3. moves to an editor window, then moves BACK to the terminal window, +-- 4. measures the mode and the first visible line afterwards. +-- +-- Provider is chosen by env (so one script covers both code paths): +-- CLAUDECODE_PROVIDER=snacks (default) -> expected to reproduce the bug +-- CLAUDECODE_PROVIDER=native -> baseline, should NOT reproduce +-- +-- Run from the repo root: +-- nvim --headless -u NONE -l scripts/repro_issue_232.lua +-- CLAUDECODE_PROVIDER=native nvim --headless -u NONE -l scripts/repro_issue_232.lua +-- +-- Exit code: 1 if the terminal re-entered terminal mode on re-focus (#232 +-- reproduced), 0 if it stayed in Normal mode (baseline / fixed). + +local script_path = debug.getinfo(1, "S").source:sub(2) +local repo_root = vim.fn.fnamemodify(script_path, ":h:h") +vim.opt.rtp:prepend(repo_root) + +local function out(msg) + io.stdout:write(msg .. "\n") +end + +local provider = vim.env.CLAUDECODE_PROVIDER or "snacks" + +-- Put an installed snacks.nvim on the runtimepath when testing that provider. +if provider == "snacks" then + local candidates = vim.fn.glob(vim.fn.expand("~/.local/share/*/lazy/snacks.nvim"), true, true) + table.insert(candidates, 1, vim.fn.expand("~/.local/share/nvim/lazy/snacks.nvim")) + local found = nil + for _, p in ipairs(candidates) do + if p ~= "" and vim.fn.isdirectory(p) == 1 then + found = p + break + end + end + if found then + vim.opt.rtp:prepend(found) + out("[setup] snacks.nvim runtimepath: " .. found) + else + out("SKIP: snacks.nvim not found on disk; cannot test the snacks provider.") + os.exit(0) + end + local ok_snacks = pcall(require, "snacks") + if not ok_snacks then + out("SKIP: failed to require('snacks').") + os.exit(0) + end +end + +local fake_claude = repo_root .. "/fixtures/issue-232/fake-claude.sh" +-- Fallback to a bare `cat` if the fixture script is missing for any reason. +local terminal_cmd = (vim.fn.filereadable(fake_claude) == 1) and fake_claude or "cat" + +-- Optional fix toggle: CLAUDECODE_AUTO_INSERT=false exercises PR #233's option. +local auto_insert = nil +if vim.env.CLAUDECODE_AUTO_INSERT == "false" then + auto_insert = false +elseif vim.env.CLAUDECODE_AUTO_INSERT == "true" then + auto_insert = true +end + +local cc = require("claudecode") +cc.setup({ + auto_start = false, + log_level = "error", + terminal_cmd = terminal_cmd, + terminal = { + provider = provider, + split_side = "right", + split_width_percentage = 0.5, + auto_close = false, + show_native_term_exit_tip = false, + auto_insert = auto_insert, + }, +}) + +local terminal = require("claudecode.terminal") + +-- 1. Open the Claude terminal, focused (this is the normal `:ClaudeCode` path). +terminal.simple_toggle({}, nil) + +-- Let the PTY spawn and Snacks wire up its autocmds. +vim.wait(800, function() + local b = terminal.get_active_terminal_bufnr() + return b ~= nil and vim.api.nvim_buf_is_valid(b) +end) + +local term_bufnr = terminal.get_active_terminal_bufnr() +if not term_bufnr or not vim.api.nvim_buf_is_valid(term_bufnr) then + out("ERROR: terminal buffer was never created; cannot run repro.") + os.exit(2) +end + +-- Find the window showing the terminal buffer. +local function term_win() + 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) == term_bufnr then + return w + end + end + return nil +end + +local tw = term_win() +if not tw then + out("ERROR: terminal window not found after open.") + os.exit(2) +end + +-- Inspect the BufEnter autocmds registered on the terminal buffer (Snacks' +-- auto_insert path registers one that calls startinsert). +local buf_enter_aucmds = vim.api.nvim_get_autocmds({ event = "BufEnter", buffer = term_bufnr }) +out(("[probe] BufEnter autocmds on terminal buffer: %d"):format(#buf_enter_aucmds)) + +-- 2. Drop to Normal mode and scroll to the TOP of the output. +vim.api.nvim_set_current_win(tw) +vim.cmd("stopinsert") +vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes([[]], true, false, true), "x", false) +vim.api.nvim_win_call(tw, function() + vim.cmd("normal! gg") +end) +local mode_at_top = vim.api.nvim_get_mode().mode +local first_visible_top = vim.fn.line("w0", tw) +out( + ("[step] after + gg in terminal: mode=%q first_visible_line=%d"):format(mode_at_top, first_visible_top) +) + +-- 3a. Move to an editor window (open the sample file in a left split). +vim.cmd("topleft vsplit") +local editor_win = vim.api.nvim_get_current_win() +vim.cmd("enew") +vim.api.nvim_buf_set_lines(0, 0, -1, false, { "editor window: pretend this is my code" }) +vim.api.nvim_set_current_win(editor_win) +out(("[step] moved to editor window: mode=%q"):format(vim.api.nvim_get_mode().mode)) + +-- 3b. Move BACK to the terminal window -- the moment under test. This is what +-- `l` does: it makes the terminal window current, firing BufEnter. +vim.api.nvim_set_current_win(tw) + +-- Snacks runs `startinsert` from the BufEnter callback; give the event loop a +-- moment to apply it. +vim.wait(300, function() + return vim.api.nvim_get_mode().mode == "t" +end) + +local mode_after_refocus = vim.api.nvim_get_mode().mode +local first_visible_after = vim.fn.line("w0", tw) +out( + ("[step] after switching BACK to terminal window: mode=%q first_visible_line=%d"):format( + mode_after_refocus, + first_visible_after + ) +) + +out("") +out("Note: a real `startinsert` only takes visible effect under an attached UI;") +out("in headless `-l` mode the pending mode change is deferred and not applied,") +out("so mode() above reads 'nt' regardless. The DETERMINISTic signal here is") +out("whether the plugin registered an auto-insert-on-focus autocmd at all -- see") +out("the [probe] line. The VISIBLE jump-to-bottom is demonstrated via agent-tty.") +out("") +out(("==== RESULT (provider=%s, auto_insert=%s) ===="):format(provider, tostring(auto_insert))) +-- The root cause of #232 is the BufEnter-driven startinsert that Snacks' +-- `auto_insert` registers on the terminal buffer. Its presence == bug present. +local reproduced = (#buf_enter_aucmds > 0) +if reproduced then + out("REPRODUCED: an auto-insert BufEnter autocmd is registered on the Claude") + out(" terminal buffer. Re-focusing the terminal window (e.g. l) fires it,") + out(" runs startinsert, and snaps the view to the bottom prompt -- discarding the") + out(" Normal-mode scroll position (issue #232).") +else + out("NOT reproduced: no auto-insert-on-focus autocmd on the terminal buffer.") + out(" Re-focusing the terminal window keeps Normal mode and preserves the scroll") + out(" position -- the behavior the reporter wants.") +end +out( + ("(observed: mode_after_refocus=%q, first_visible top=%d -> after=%d)"):format( + mode_after_refocus, + first_visible_top, + first_visible_after + ) +) + +-- Clean up the PTY job. +pcall(function() + terminal.close() +end) + +os.exit(reproduced and 1 or 0)