Skip to content

feat(nvim): make Core's terminal splits escapable - #317

Merged
Gerrrt merged 2 commits into
mainfrom
claude/neovim-plugin-exploration-mw39iv
Aug 1, 2026
Merged

feat(nvim): make Core's terminal splits escapable#317
Gerrrt merged 2 commits into
mainfrom
claude/neovim-plugin-exploration-mw39iv

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

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 under nvim/. 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 via jobstart(..., { term = true }) and calls startinsert, so it has the identical trap — and since :q needs 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:

Terminal Where Label
Claude's split plugins/claudecode-nvim.lua (TermOpen autocmd) Claude
pytest split config/autocmds.lua (inline after jobstart) test output

cheatsheet.lua documents 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:

Key ASCII What it does at an interactive prompt
<C-h> 8 backspace
<C-j> 10 newline
<C-w> 23 delete word backward

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, TermOpen also fires for the pytest split and any :terminal, so rather than matching the term:// 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:

local ok, terminal = pcall(require, "claudecode.terminal")
if not ok or terminal.get_active_terminal_bufnr() ~= ev.buf then return end

Deferred one tick via vim.schedule, because TermOpen fires during termopen(), 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 audit246 pass, 0 skip, 0 fail. luacheck clean across 98 files.

Exercised headlessly against the real config:

  • Driving the actual <leader>tt mapping's callback opens the pytest terminal, which gets exactly <M-h> <M-j> <M-k> <M-l> labelled "Leave test output, window/pane …".
  • Claude's split still gets them labelled "Leave Claude, …" after the refactor.
  • An unrelated :terminal opened in the same session gets none — proving the scoping, for both paths.
  • Each map resolves to <C-\><C-N><Cmd>TmuxNavigate…<CR>, and TmuxNavigateLeft resolves through vim-tmux-navigator's lazy cmd loading.
  • The cheatsheet renders the new card without truncation or reflow.

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.

Copilot AI review requested due to automatic review settings August 1, 2026 05:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread CHANGELOG.md Outdated
Comment on lines +49 to +50
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 live

So 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 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 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
@Gerrrt
Gerrrt force-pushed the claude/neovim-plugin-exploration-mw39iv branch from 201dffd to e949a04 Compare August 1, 2026 05:51
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
@Gerrrt Gerrrt changed the title feat(nvim): make Claude's terminal split escapable feat(nvim): make Core's terminal splits escapable Aug 1, 2026
@Gerrrt
Gerrrt merged commit 630cc03 into main Aug 1, 2026
14 checks passed
@Gerrrt
Gerrrt deleted the claude/neovim-plugin-exploration-mw39iv branch August 1, 2026 06:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants