Skip to content

Commit 6f4a2ce

Browse files
violence-maxxieanfengThomasK33claude
authored
feat(terminal): add auto_insert option to preserve scroll position (#233)
## Summary - Add `terminal.auto_insert` config option (default: `true`) that controls whether the terminal auto-enters insert mode when focused - When set to `false`, switching back to the Claude Code terminal preserves the scroll position instead of jumping to the bottom - Affects all terminal providers (native, snacks) and diff tab terminal handling ## Usage ```lua require("claudecode").setup({ terminal = { auto_insert = false, -- Stay in normal mode, preserve scroll position }, }) ``` ## Test plan - [x] Added 3 unit tests for `auto_insert` config validation - [x] All existing tests pass - [x] Manual testing: verified scroll position is preserved when switching back to terminal Closes #232 --------- Signed-off-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: xieanfeng <xieanfeng@x2robot.com> Co-authored-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 298a3e6 commit 6f4a2ce

16 files changed

Lines changed: 532 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- `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))
88
- `: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))
99
- `: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))
10+
- `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. `<C-w>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))
1011

1112
### Bug Fixes
1213

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,11 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
353353
diff_split_width_percentage = nil, -- e.g. 0.20 to give diffs more room
354354
provider = "auto", -- "auto", "snacks", "native", "external", "none", or custom provider table
355355
auto_close = true,
356+
-- Auto-enter insert/terminal mode whenever the Claude terminal window gains
357+
-- focus. Set to false to stay in Normal mode and preserve your scroll position
358+
-- when switching back to the terminal (e.g. via <C-w>l); press `i` to type.
359+
-- Note: false also opens the terminal in Normal mode (it gates start-insert too).
360+
auto_insert = true,
356361
snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below
357362
-- Work around a Neovim core bug (< 0.12.2) that fragments large pastes into
358363
-- the terminal, making Cmd+V appear to truncate ([#161]). true | false | "auto"

fixtures/issue-232/README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Fixture: issue #232 — terminal jumps to the bottom / re-enters insert mode on re-focus
2+
3+
> [FEATURE] Terminal window should restore scroll position when switching back
4+
> from editor window — https://github.com/coder/claudecode.nvim/issues/232
5+
>
6+
> Duplicate of #145; an implementation already exists as **PR #233**
7+
> (`terminal.auto_insert`).
8+
9+
## Symptom
10+
11+
With the **Snacks** terminal provider (the default when `snacks.nvim` is
12+
installed), reading Claude's output in Normal mode and then switching focus back
13+
into the terminal window (e.g. `<C-w>l` / `<C-l>`) throws you back into
14+
terminal-mode at the bottom prompt, discarding your scroll/reading position.
15+
16+
## Root cause
17+
18+
`build_opts` in `lua/claudecode/terminal/snacks.lua` passes Snacks
19+
`auto_insert = focus`. Snacks' `auto_insert` registers a **buffer-local
20+
`BufEnter` autocmd that runs `startinsert` on every entry** into the terminal
21+
buffer, so re-focusing the window forces terminal-mode and snaps to the prompt.
22+
The **native** provider registers no such autocmd, so it does NOT exhibit this
23+
on plain window navigation (it only force-inserts on `:ClaudeCodeFocus`/toggle).
24+
25+
`snacks_win_opts` cannot fix this from user config: it is merged only into the
26+
Snacks `win` table, whereas `auto_insert`/`start_insert` are top-level
27+
`snacks.terminal.Opts` fields (this is exactly what the #145 reporter tried).
28+
29+
## Run it
30+
31+
```sh
32+
source fixtures/nvim-aliases.sh
33+
34+
# Reproduce (Snacks, default):
35+
CLAUDECODE_PROVIDER=snacks vv issue-232
36+
37+
# Baseline (native — does NOT reproduce):
38+
CLAUDECODE_PROVIDER=native vv issue-232
39+
```
40+
41+
The fixture uses `fake-claude.sh` (200 lines of output + a live `cat` prompt) in
42+
place of the real Claude CLI, so no auth/network is needed.
43+
44+
### Manual steps (matches the issue report)
45+
46+
1. Press `<leader>r` (or `:Repro`) to lay out: sample file (left) + Claude
47+
terminal (right, focused).
48+
2. In the terminal press `<C-\><C-n>` to enter Normal mode, then `gg` to scroll
49+
to the top (you should see `claude output line 001`).
50+
3. Press `<C-h>` to jump to the editor window.
51+
4. Press `<C-l>` to jump back to the terminal.
52+
53+
Every window's statusline shows `MODE=%{mode()}` so you can see the mode flip.
54+
55+
- **Snacks (bug):** after step 4 the statusline shows `MODE=t`, `-- TERMINAL --`
56+
appears, and the view jumps to `claude output line 200` / the `>` prompt.
57+
- **Native (baseline):** after step 4 the statusline stays `MODE=n` and the view
58+
stays at `claude output line 001`.
59+
60+
## Deterministic / headless check
61+
62+
`scripts/repro_issue_232.lua` asserts the mechanism (the `BufEnter``startinsert`
63+
autocmd) without a UI:
64+
65+
```sh
66+
CLAUDECODE_PROVIDER=snacks nvim --headless -u NONE -l scripts/repro_issue_232.lua # exit 1 (reproduced)
67+
CLAUDECODE_PROVIDER=native nvim --headless -u NONE -l scripts/repro_issue_232.lua # exit 0 (baseline)
68+
CLAUDECODE_PROVIDER=snacks CLAUDECODE_AUTO_INSERT=false nvim --headless -u NONE -l scripts/repro_issue_232.lua # exit 0 (fixed by PR #233)
69+
```
70+
71+
(The visible mode flip needs an attached UI; in headless `-l` mode `startinsert`
72+
is deferred and never applied, so the script keys its verdict off the autocmd
73+
probe, not `mode()`.)
74+
75+
## Workarounds available today (before PR #233 lands)
76+
77+
- Use `terminal = { provider = "native" }` if you rely on `<C-w>l`-style window
78+
navigation (preserves scroll/Normal mode on re-focus).
79+
- Or copy the snacks provider into a custom provider and drop the `startinsert`
80+
/ `auto_insert` calls (maintainer's suggestion on #145).
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Sample editor buffer (issue #232)
2+
3+
This file stands in for "my code" in the left window. The reproduction workflow:
4+
5+
1. Read Claude's output in the RIGHT (terminal) window in Normal mode.
6+
2. Jump to THIS window with `<C-h>` to check some code.
7+
3. Jump back to the terminal with `<C-l>` to keep reading.
8+
9+
With the snacks provider, step 3 throws you back into terminal mode at the
10+
bottom prompt -- the scroll position from step 1 is lost.
11+
12+
(Line A) the quick brown fox jumps over the lazy dog
13+
(Line B) the quick brown fox jumps over the lazy dog
14+
(Line C) the quick brown fox jumps over the lazy dog

fixtures/issue-232/fake-claude.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env bash
2+
# Fake "Claude" CLI used to reproduce issue #232 without a real Claude session.
3+
#
4+
# It prints a long block of numbered output (so there is genuine scrollback to
5+
# lose) and then drops into `cat`, which keeps the job alive with a prompt-like
6+
# bottom line. That is all the reproduction needs: a live terminal buffer whose
7+
# cursor/PTY lives at the BOTTOM, while the user reads near the TOP in Normal
8+
# mode.
9+
for i in $(seq 1 200); do
10+
printf 'claude output line %03d ........................................\n' "$i"
11+
done
12+
printf '\n--- END OF OUTPUT (scroll UP to read from line 001) ---\n'
13+
printf '> '
14+
exec cat

fixtures/issue-232/init.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Fixture for issue #232:
2+
-- "[FEATURE] Terminal window should restore scroll position when switching
3+
-- back from editor window"
4+
--
5+
-- Repro of the underlying behavior: with the Snacks terminal provider (the
6+
-- default when snacks.nvim is installed), switching focus BACK into the Claude
7+
-- terminal window re-enters terminal/insert mode and the view jumps to the
8+
-- bottom (the prompt), discarding the Normal-mode scroll position the user was
9+
-- reading at.
10+
--
11+
-- Provider is selectable so you can A/B the two code paths with ONE fixture:
12+
-- CLAUDECODE_PROVIDER=snacks vv issue-232 (default; reproduces the bug)
13+
-- CLAUDECODE_PROVIDER=native vv issue-232 (does NOT reproduce -> baseline)
14+
--
15+
-- See README.md in this directory for the exact manual steps.
16+
require("config.lazy")
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
-- Bootstrap lazy.nvim
2+
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
3+
if not (vim.uv or vim.loop).fs_stat(lazypath) then
4+
local lazyrepo = "https://github.com/folke/lazy.nvim.git"
5+
local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
6+
if vim.v.shell_error ~= 0 then
7+
vim.api.nvim_echo({
8+
{ "Failed to clone lazy.nvim:\n", "ErrorMsg" },
9+
{ out, "WarningMsg" },
10+
{ "\nPress any key to exit..." },
11+
}, true, {})
12+
vim.fn.getchar()
13+
os.exit(1)
14+
end
15+
end
16+
vim.opt.rtp:prepend(lazypath)
17+
18+
vim.g.mapleader = " "
19+
vim.g.maplocalleader = "\\"
20+
21+
-- Resolve the claudecode.nvim checkout that owns this fixture (XDG_CONFIG_HOME is
22+
-- the `fixtures/` dir under the `vv` launcher, so its parent is the repo root).
23+
-- Works from a normal checkout or a git worktree.
24+
local repo_root = vim.fn.fnamemodify(vim.env.XDG_CONFIG_HOME or vim.fn.getcwd(), ":h")
25+
vim.g.claudecode_dev_dir = repo_root
26+
27+
require("lazy").setup({
28+
spec = {
29+
{ import = "plugins" },
30+
},
31+
install = { colorscheme = { "habamax" } },
32+
checker = { enabled = false },
33+
})
34+
35+
-- Window navigation like the issue reporter's setup (Ctrl-h / Ctrl-l). The
36+
-- terminal-mode maps leave terminal mode FIRST, then move -- so any jump back to
37+
-- the bottom is caused by the provider re-entering insert mode, not by the maps.
38+
vim.keymap.set("n", "<C-h>", "<C-w>h", { silent = true, desc = "Window left" })
39+
vim.keymap.set("n", "<C-l>", "<C-w>l", { silent = true, desc = "Window right" })
40+
vim.keymap.set("t", "<C-h>", [[<C-\><C-n><C-w>h]], { silent = true, desc = "Window left (from terminal)" })
41+
vim.keymap.set("t", "<C-l>", [[<C-\><C-n><C-w>l]], { silent = true, desc = "Window right (from terminal)" })
42+
vim.keymap.set("t", "<Esc><Esc>", [[<C-\><C-n>]], { silent = true, desc = "Exit terminal mode (double esc)" })
43+
44+
-- Make the current mode + window visible in EVERY window's statusline so a
45+
-- terminal snapshot reveals whether we landed in Normal ('n') or Terminal ('t')
46+
-- mode after switching back.
47+
vim.o.laststatus = 2
48+
vim.o.statusline = " MODE=%{mode()} win=%{winnr()} %f "
49+
50+
-- One-shot layout helper so the reproduction is deterministic and scriptable:
51+
-- 1. edit the sample file in the left window
52+
-- 2. open the Claude terminal (focused) in a right split
53+
-- After calling this you are IN the terminal, in terminal mode, at the bottom.
54+
_G.repro_setup = function()
55+
vim.cmd("edit " .. vim.fn.fnameescape(vim.g.claudecode_dev_dir .. "/fixtures/issue-232/example/notes.md"))
56+
require("claudecode.terminal").simple_toggle({}, nil)
57+
end
58+
vim.api.nvim_create_user_command("Repro", _G.repro_setup, { desc = "Set up issue #232 reproduction layout" })
59+
60+
vim.schedule(function()
61+
local provider = vim.env.CLAUDECODE_PROVIDER or "snacks"
62+
vim.notify(
63+
("[issue-232] provider=%s -- run :Repro (or <leader>r), then in the terminal: <C-\\><C-n>, gg, <C-h>, <C-l>"):format(
64+
provider
65+
),
66+
vim.log.levels.INFO
67+
)
68+
end)
69+
70+
vim.keymap.set("n", "<leader>r", "<cmd>Repro<cr>", { desc = "issue-232 repro layout" })
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
-- Load the local claudecode.nvim checkout (resolved in lua/config/lazy.lua).
2+
--
3+
-- Provider and the terminal command are env-controlled so the SAME fixture
4+
-- reproduces the bug (snacks) and shows the baseline (native):
5+
-- CLAUDECODE_PROVIDER=snacks|native (default: snacks)
6+
--
7+
-- terminal_cmd points at fake-claude.sh -- a long-output, stays-alive stand-in
8+
-- for the real Claude CLI, so the repro needs no network and no auth.
9+
local provider = vim.env.CLAUDECODE_PROVIDER or "snacks"
10+
local fake_claude = vim.g.claudecode_dev_dir .. "/fixtures/issue-232/fake-claude.sh"
11+
12+
return {
13+
"coder/claudecode.nvim",
14+
dir = vim.g.claudecode_dev_dir,
15+
dependencies = { "folke/snacks.nvim" },
16+
keys = {
17+
{ "<leader>ac", "<cmd>ClaudeCode<cr>", desc = "Toggle Claude" },
18+
{ "<leader>af", "<cmd>ClaudeCodeFocus<cr>", desc = "Focus Claude" },
19+
},
20+
---@type PartialClaudeCodeConfig
21+
opts = {
22+
auto_start = false, -- no server/port/lockfile needed for this UI repro
23+
log_level = "debug",
24+
terminal_cmd = fake_claude,
25+
terminal = {
26+
provider = provider,
27+
split_side = "right",
28+
split_width_percentage = 0.45,
29+
auto_close = false,
30+
},
31+
},
32+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- snacks.nvim is the default terminal backend for claudecode.nvim ("auto"
2+
-- prefers it when installed). Its terminal is what re-enters insert mode on
3+
-- focus (auto_insert), which is the behavior issue #232 is about.
4+
return {
5+
"folke/snacks.nvim",
6+
priority = 1000,
7+
lazy = false,
8+
---@type snacks.Config
9+
opts = {
10+
-- Nothing special; we only need Snacks.terminal available.
11+
},
12+
}

lua/claudecode/diff.lua

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -392,17 +392,20 @@ local function display_terminal_in_new_tab()
392392
apply_window_options(terminal_win, terminal_options)
393393

394394
-- Set up autocmd to enter terminal mode when focusing this terminal window
395-
vim.api.nvim_create_autocmd("BufEnter", {
396-
buffer = terminal_bufnr,
397-
group = get_autocmd_group(),
398-
callback = function()
399-
-- Only enter insert mode if we're in a terminal buffer and in normal mode
400-
if vim.bo.buftype == "terminal" and vim.fn.mode() == "n" then
401-
vim.cmd("startinsert")
402-
end
403-
end,
404-
desc = "Auto-enter terminal mode when focusing Claude Code terminal",
405-
})
395+
local terminal_auto_insert = not config or not config.terminal or config.terminal.auto_insert ~= false
396+
if terminal_auto_insert then
397+
vim.api.nvim_create_autocmd("BufEnter", {
398+
buffer = terminal_bufnr,
399+
group = get_autocmd_group(),
400+
callback = function()
401+
-- Only enter insert mode if we're in a terminal buffer and in normal mode
402+
if vim.bo.buftype == "terminal" and vim.fn.mode() == "n" then
403+
vim.cmd("startinsert")
404+
end
405+
end,
406+
desc = "Auto-enter terminal mode when focusing Claude Code terminal",
407+
})
408+
end
406409

407410
-- Size the terminal for the diff (unless the user opted out via auto_resize_terminal).
408411
resize_terminal_for_diff(terminal_win, "diff")
@@ -709,17 +712,22 @@ local function setup_new_buffer(
709712
vim.b[new_buf].claudecode_diff_target_win = target_win_for_meta
710713

711714
if config and config.diff_opts and config.diff_opts.keep_terminal_focus then
715+
local auto_insert = not config.terminal or config.terminal.auto_insert ~= false
712716
vim.schedule(function()
713717
if terminal_win_in_new_tab and vim.api.nvim_win_is_valid(terminal_win_in_new_tab) then
714718
vim.api.nvim_set_current_win(terminal_win_in_new_tab)
715-
vim.cmd("startinsert")
719+
if auto_insert then
720+
vim.cmd("startinsert")
721+
end
716722
return
717723
end
718724

719725
local terminal_win = find_claudecode_terminal_window()
720726
if terminal_win then
721727
vim.api.nvim_set_current_win(terminal_win)
722-
vim.cmd("startinsert")
728+
if auto_insert then
729+
vim.cmd("startinsert")
730+
end
723731
end
724732
end)
725733
end

0 commit comments

Comments
 (0)