feat(nvim): make Core's terminal splits escapable - #317
Conversation
There was a problem hiding this comment.
Pull request overview
Adds discoverable, Claude-specific terminal escape and navigation controls.
Changes:
- Adds buffer-local
<M-h/j/k/l>terminal navigation. - Documents terminal-mode controls in the cheatsheet.
- Updates the Unreleased changelog.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
nvim/lua/gerrrt/plugins/claudecode-nvim.lua |
Adds scoped terminal navigation mappings. |
nvim/lua/gerrrt/cheatsheet.lua |
Documents terminal controls. |
CHANGELOG.md |
Records the user-visible change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| said so: this was the first terminal buffer Core had ever shipped, and it had no terminal-mode | ||
| keymaps at all. `cheatsheet.lua` now documents `<C-\><C-n>` / `i` alongside buffer-local |
There was a problem hiding this comment.
Correct — the claim was false. Fixed in e949a04.
config/autocmds.lua:206-213 opens a genuine terminal buffer, and it even puts you in terminal mode:
vim.cmd("botright split | enew | resize 15")
local jid = vim.fn.jobstart(argv, { cwd = root, term = true })
...
vim.cmd("startinsert") -- so output scrolls liveSo not only was Claude's not the first terminal buffer Core ships — the pytest split has the identical gap. <C-h/j/k/l> don't work there either, and since :q needs normal mode, closing it also requires <C-\><C-n> first.
Reworded to state the actual gap and to name the pytest terminal as sharing it:
nothing in the config said so — Core ships no terminal-mode keymaps at all (
mode = "t"appears nowhere undernvim/). The pytest runner inconfig/autocmds.luaalready opened a terminal split and shares that gap; it simply never surfaced there, because it is read-only output you glance at and close rather than a prompt you sit inside.
The commit message carried the same error, so it was amended rather than fixed only in the file — /release-notes drafts from Conventional Commits, so a false claim in the body would have propagated into the next release's notes. The PR description is updated too.
Leaving the pytest terminal itself alone here: giving it the same treatment is a real follow-up, but it is a separate change from the Claude integration and shouldn't ride along in this diff.
Generated by Claude Code
A Neovim terminal buffer forwards every keystroke to the program inside it, and auto_insert lands you there on open. So Core's <C-h/j/k/l> (vim-tmux-navigator, normal mode only) never reached Neovim, Claude swallowed them, and the split read as a trap whose apparent only exit was quitting Claude. <C-\><C-n> — Neovim's one reserved terminal-mode key — was always the way out, but nothing in the config said so: Core ships no terminal-mode keymaps at all (mode = "t" appears nowhere under nvim/). The pytest runner in config/autocmds.lua already opened a terminal split and shares that gap; it simply never surfaced there, because it is read-only output you glance at and close rather than a prompt you sit inside. The real defect was discoverability, not behavior. cheatsheet.lua now carries <C-\><C-n> and `i` in the Claude card, and a TermOpen autocmd sets buffer-local <M-h/j/k/l> that leave terminal mode and navigate in one keystroke. The navigator's own <C-h/j/k/l> are deliberately NOT widened into terminal mode: <C-h> is ASCII 8 (backspace) and <C-j> is ASCII 10 (newline), so claiming them would break editing at Claude's prompt. <C-w> is out for the same reason (delete-word-backward). <M-…> is unclaimed by the TUI. mini.move owns <A-h/j/k/l> in normal/visual only, so there is no real collision. Scoping asks the plugin which buffer is its own via claudecode.terminal.get_active_terminal_bufnr() rather than matching the `term://` buffer name — TermOpen also fires for that pytest split, and a name match on "claude" would additionally hit a plain shell opened in a directory containing that word. The check is deferred one tick because TermOpen fires during termopen(), before the provider records the new buffer as active. Verified: the maps land on Claude's buffer and an unrelated :terminal opened in the same session gets none; TmuxNavigate* resolves through vim-tmux-navigator's lazy cmd loading; the cheatsheet renders the new rows without reflowing the card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvMkCtfJugUuxz6HCa1NSZ
201dffd to
e949a04
Compare
The pytest split in config/autocmds.lua opens a real terminal buffer and calls startinsert, so it has always had the identical trap Claude's split did: <C-h/j/k/l> are swallowed, and since :q needs normal mode, even closing it requires <C-\><C-n> first. It only escaped notice because it is read-only output you glance at rather than a prompt you sit inside. Extract the rule into utils/term.lua and have both terminals opt in, rather than duplicating the keymap loop and — more importantly — the reasoning about why the maps ride <M-…> instead of widening the navigator's own keys. That reasoning is the part worth having in exactly one place: <C-h> is ASCII 8 (backspace), <C-j> is ASCII 10 (newline), <C-w> is delete-word-backward, so claiming any of them breaks editing at an interactive prompt. The maps stay buffer-local, so a terminal Core did not open keeps its keys untouched. Claude's buffer is still identified by asking the plugin; the pytest buffer needs no such dance because jobstart(term=true) leaves it current. cheatsheet.lua consolidates this into one "Terminal buffers" card instead of a copy in each terminal's card — the panel renders every card simultaneously, so a second copy would only be a thing to keep in step. Verified: the pytest terminal gets <M-h/j/k/l> labelled "test output", Claude's still gets them labelled "Claude", and an unrelated :terminal opened in the same session gets none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvMkCtfJugUuxz6HCa1NSZ
Follow-up to #316, from real use: once Claude opens,
<C-h/j/k/l>don't work and the split looks like it can only be left by quitting Claude.The cause
A Neovim terminal buffer forwards every keystroke to the program inside it, and both of Core's terminals call
startinsert, so you land in terminal mode. Core's navigation is normal-mode only:{ "<C-h>", "<cmd>TmuxNavigateLeft<cr>", desc = "Window/pane left" },so those keys never reach Neovim — the program eats them.
<C-\><C-n>, Neovim's one reserved terminal-mode key, was always the way out. But nothing in the config said so: Core shipped no terminal-mode keymaps at all —mode = "t"appeared nowhere undernvim/. The defect was discoverability, not behavior.Scope grew during review
The PR originally claimed Claude's was the first terminal buffer Core ships and deferred everything else. Review correctly pointed out that's false: the pytest runner (
config/autocmds.lua:206-213) already opens one viajobstart(..., { term = true })and callsstartinsert, so it has the identical trap — and since:qneeds normal mode, even closing it requires<C-\><C-n>first. It only escaped notice because that split is read-only output you glance at rather than a prompt you sit inside.Rather than ship a fix for one terminal and a known-identical bug in the other, both now opt into the same rule.
The change
nvim/lua/gerrrt/utils/term.lua(new) owns the rule and, more importantly, the reasoning. Both terminals call it:plugins/claudecode-nvim.lua(TermOpenautocmd)Claudeconfig/autocmds.lua(inline afterjobstart)test outputcheatsheet.luadocuments it as one Terminal buffers card —<C-\><C-n>,<M-h/j/k/l>,i / a— not a copy in each terminal's card. The panel renders every card simultaneously, so a second copy would only be a thing to keep in step.Why not just widen the navigator's own maps
Adding
mode = { "n", "t" }to<C-h/j/k/l>is the obvious fix and it's wrong:<C-h><C-j><C-w>Claiming any of those breaks editing.
<M-…>is unclaimed by the TUIs involved, so it carries the maps instead. mini.move owns<A-h/j/k/l>in normal/visual only — a different mode, so no real collision — and the overload reads the same way: "move in that direction".Scoping
Maps are buffer-local, so a terminal Core didn't open (
:terminal, a plugin's own) keeps its keys untouched.For Claude's buffer,
TermOpenalso fires for the pytest split and any:terminal, so rather than matching theterm://name — which is what the plugin does internally, and which would also hit a plain shell opened in a directory containing the word "claude" — it asks the plugin:Deferred one tick via
vim.schedule, becauseTermOpenfires duringtermopen(), before the provider records the new buffer as active. The pytest side needs no such dance —jobstart(term = true)leaves its buffer current.Verification
make audit— 246 pass, 0 skip, 0 fail.luacheckclean across 98 files.Exercised headlessly against the real config:
<leader>ttmapping's callback opens the pytest terminal, which gets exactly<M-h> <M-j> <M-k> <M-l>labelled "Leave test output, window/pane …".:terminalopened in the same session gets none — proving the scoping, for both paths.<C-\><C-N><Cmd>TmuxNavigate…<CR>, andTmuxNavigateLeftresolves through vim-tmux-navigator's lazycmdloading.Note on the branch
The branch name is reused per this session's configuration, but #316 is merged — this is restarted from
origin/main, not stacked on merged history.