Skip to content

feat: add multi-session terminal support#164

Open
snirt wants to merge 35 commits intocoder:mainfrom
snirt:main
Open

feat: add multi-session terminal support#164
snirt wants to merge 35 commits intocoder:mainfrom
snirt:main

Conversation

@snirt
Copy link
Copy Markdown

@snirt snirt commented Dec 17, 2025

Add ability to run multiple concurrent Claude Code terminal sessions with session management, smart ESC handling, and session-aware selection tracking.

New commands:

  • ClaudeCodeNew: Create a new terminal session
  • ClaudeCodeSessions: Show session picker (supports fzf-lua)
  • ClaudeCodeSwitch: Switch to session by number
  • ClaudeCodeCloseSession: Close session by number or active session

New features:

  • Smart ESC handling: double-tap ESC to exit terminal mode, single ESC sends to terminal (configurable via esc_timeout)
  • Session-aware selection tracking and message routing
  • OSC title handler for capturing terminal title changes
  • Configurable terminal keymaps (terminal.keymaps.exit_terminal)

New modules:

  • lua/claudecode/session.lua: Session lifecycle management
  • lua/claudecode/terminal/osc_handler.lua: Terminal title detection

Add ability to run multiple concurrent Claude Code terminal sessions with
session management, smart ESC handling, and session-aware selection tracking.

New commands:
- ClaudeCodeNew: Create a new terminal session
- ClaudeCodeSessions: Show session picker (supports fzf-lua)
- ClaudeCodeSwitch: Switch to session by number
- ClaudeCodeCloseSession: Close session by number or active session

New features:
- Smart ESC handling: double-tap ESC to exit terminal mode, single ESC
  sends to terminal (configurable via esc_timeout)
- Session-aware selection tracking and message routing
- OSC title handler for capturing terminal title changes
- Configurable terminal keymaps (terminal.keymaps.exit_terminal)

New modules:
- lua/claudecode/session.lua: Session lifecycle management
- lua/claudecode/terminal/osc_handler.lua: Terminal title detection
@snirt
Copy link
Copy Markdown
Author

snirt commented Dec 18, 2025

Found a bug in this PR - killing the claude-code using ctrl-c closes the instance, but does not remove the session record from the session list. I'll fix it soon.

Snir Turgeman and others added 25 commits December 18, 2025 17:53
Add jobresize() calls to notify the terminal job of window dimensions
when switching between sessions. This fixes the cursor appearing in
the wrong position and line shifting after session switch.

The terminal job needs to know its window dimensions to correctly
calculate cursor position and line wrapping. Without this, the
terminal renders based on stale window state from the previous session.
- Add clickable tabbar for session switching (floating and winbar modes)
- Support left-click to switch sessions, middle-click to close
- Add close button (✕) on each tab with same background as tab
- Add new session button (+) for creating new sessions
- Add scroll wheel support to cycle sessions in floating tabbar
- Add mouse selection tracking (LeftRelease/LeftDrag) for better selection capture
- Fix intentional close handling to suppress exit error on X click
- Add config validation for terminal.tabs options
… available

When a Claude terminal session exits (e.g., via Ctrl-C), the window now
stays open and switches to display another available session instead of
closing entirely. This provides a smoother multi-session experience.

Changes:
- Add session switching logic to TermClose handlers in both providers
- Disconnect old terminal instance from window before buffer switch
- Check session existence before destroying to prevent double-destruction
- Add close_session_keep_window() for explicit session switching
Add dedicated window_manager module that owns the single terminal window.
This separates window lifecycle from buffer lifecycle, fixing issues where:
- Creating new sessions would reset window to default size
- Switching between tabs could cause window duplication
- Closing tabs could leave windows in wrong positions

Changes:
- Add window_manager.lua: singleton that manages THE terminal window
- Refactor snacks.lua: create buffers only, delegate window to manager
- Refactor native.lua: simplified buffer-only management
- Update terminal.lua: initialize window_manager, improve tab navigation
- New tab now selects the created session
- Closing tab selects previous tab (or next if first)
- Add "Fork Features" section highlighting multi-session support and
  visual tab bar features unique to this fork
- Add "Recommended Configuration" with practical floating window setup
- Update all repo references from coder/claudecode.nvim to snirt/claudecode.nvim
- Add multi-session keymaps to installation example
- Update CHANGELOG with new features and bug fixes
Fixes #1

- Add defense-in-depth PID recovery from sessions and terminal buffers
- Kill entire process tree (not just direct children) using process groups
- Follow up with SIGKILL for any survivors after graceful SIGTERM
- Add retry mechanism for PID tracking in snacks.lua (handles delayed job_id)
- Track PIDs in external terminal provider
- Add VimLeavePre autocmd to call cleanup_all() before server stops
- Validate cleanup_strategy config option

Tests:
- Unit tests for defense-in-depth PID recovery
- Integration tests with real processes verifying actual termination
Add WinEnter autocommand to selection tracking so keyboard navigation
(ctrl-h, ctrl-l, :wincmd) properly updates the file reference in Claude
Code. Previously only mouse clicks triggered updates.

The fix cancels pending debounce timers and uses a 10ms delay to ensure
window/buffer state is settled, matching the existing mouse handler
behavior.

Closes #2
When switching back to a tab containing the Claude Code terminal,
Neovim re-equalizes window widths causing the terminal to appear
narrow. Add restore_configured_width() helper that recalculates
and applies the configured split_width_percentage on TabEnter
and VimResized events. WinEnter is intentionally not modified
to preserve manual user resizing within a tab.
Downgrade ECONNRESET/EOF/EPIPE client read errors to debug-level
logs since they are expected when the terminal closes. Also make
session.destroy_session idempotent by logging at debug level
instead of warn when a session is already destroyed.
…der#165)

Add snacks_picker_list filetype to the exclusion list in:
- find_main_editor_window(): prevents picker from being selected as target
- _create_diff_view_from_window(): creates split when picker is focused

This fixes diff view behavior when using snacks.nvim picker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: ide diagnostics without URI schema

* mock method
Cherry-picked from upstream coder/claudecode.nvim with conflict resolution.
Preserves on_disconnect_cleanup for expected disconnection errors while
adopting centralized _disconnect_client pattern.
Cherry-picked from upstream coder/claudecode.nvim.
Adds a dedicated describe block with 8 tests covering the planned
3-state ESC machine behavior (1x/2x ESC + timeout sends raw bytes,
3x ESC exits terminal mode). Tests currently produce 2 failures
against the existing 2-state implementation.
Tests 3 and 4 now assert the key 3-state invariant: 2nd ESC must
not exit terminal mode. Failing tests: double-ESC timeout sends 2x
ESC, triple-ESC exits (not double), rapid triple-ESC exits, stale
callback is a no-op.
- Assert timer:stop() called before timer:start() on 2nd ESC (libuv safety)
- Add mode assertion ("t") to Path 2 esc_timeout=0 keymap test
- Add clarifying comment on stale_callback capture in stale-timer test
…e Code

Extends smart ESC handler from 2-state to 3-state machine.
Single ESC (timeout) and double ESC (timeout) now forward raw ESC
bytes to Claude Code, enabling cancel and rewind features.
Triple ESC exits Neovim terminal mode.

Closes #4
The previous implementation stopped the timer on 2nd ESC but never
restarted it, permanently breaking the double-ESC timeout path that
sends 2x raw ESC bytes to Claude Code for the rewind feature.

Also update the test mock to track _stop_calls so the restart assertion
remains accurate after stop+start leaves _stopped=false.
- Update state shape doc comment: count is 1|2 (never 0 stored in table)
- Update setup_terminal_keymaps doc to not assume double-ESC fallback
- Remove dead nil-timer guard in count=1 branch (new_timer() nil would
  have already crashed in the count=0 branch that created the state)
@Beloin
Copy link
Copy Markdown

Beloin commented Mar 21, 2026

Hi @snirt . Will you keep pushing this forward? This will close two open issues:

snirt added 2 commits March 23, 2026 18:30
Sidebar toggles (file explorer, etc.) were triggering SIGWINCH via
jobresize on every WinEnter, causing the TUI to redraw and reset its
scroll position. Now jobresize is only sent immediately when the user
enters the terminal window itself; otherwise the resize is marked
pending and flushed on TermEnter.
…minals

Buffer-local Ctrl+h/j/k/l keymaps in terminal and normal mode allow
navigating between splits without affecting other terminal buffers.
Remembers the mode (terminal/normal) and restores it on BufEnter.
Enabled by default, configurable via terminal.split_navigation = false.
@gogongxt
Copy link
Copy Markdown

LGTM

@gogongxt
Copy link
Copy Markdown

He, What's the process of this pr. Is this usable?
Or maybe we could merge this first and resolve potential problem later if the pr doesn't make a break change.

snirt added 7 commits April 28, 2026 10:14
…uard

Adds normal-mode k/<Up> and unifies mouse wheel handling so users can scroll
through Claude Code's conversation history without leaving the terminal:

- k/<Up> in normal mode walks the cursor through Neovim's terminal scrollback
  and, once at line 1, sends SGR scroll-up to the TUI so Claude Code scrolls
  back without leaving normal mode.
- j/<Down> mirrors that downward, sending SGR scroll-down at the last line.
- <ScrollWheelUp>/<ScrollWheelDown> are bound in both normal and terminal
  modes so the same guarded path runs regardless of mode.
- Over-scroll guard inspects the rendered bottom rows of the terminal window
  via screenchar and blocks further scroll-ups once Claude Code is past the
  conversation top (avoids the empty-region-at-bottom glitch that comes from
  Claude's TUI redrawing on every event with no internal clamp).
- Suppresses <LeftDrag> in terminal mode so mouse drags no longer leak SGR
  escape sequences as garbage into the prompt; Shift+drag still works for
  text selection because Neovim handles it natively.
- New scroll_up_enabled config (default false) keeps the legacy
  exit-terminal-on-wheel behaviour available for users who prefer browsing
  Neovim scrollback over scrolling the TUI.
Default Neovim behaviour scrolls the buffer view, which over-scrolls past
the buffer end and forces a manual return. Bound in both normal and
terminal modes; sends a window_height - 2 burst of SGR scroll events
(matching Vim's <C-f>/<C-b> page convention). PageUp reuses the visual
over-scroll guard and stops the burst early if Claude Code is at the top.
Document triple-ESC, TUI scroll-back, split-nav, process cleanup,
server hooks, and tab bar config keys.
Previously <LeftDrag> in terminal mode was suppressed with <Nop>, leaving
the user stuck in terminal mode. Now first drag tick exits terminal,
positions cursor at mouse, and starts char-wise visual; subsequent ticks
extend natively via mouse=a.
ensure_window cached state.winid was reused across tabs because
nvim_win_is_valid is true for windows in any tabpage. Opening Claude in
tab 2 reused tab 1's winid and nvim_set_current_win bumped focus to
tab 1. is_visible had the same blind-spot, so toggling in tab 2 closed
tab 1's terminal.

Add a win_in_current_tab helper (uses nvim_win_get_tabpage) and gate
both ensure_window and is_visible on it. Each tab now gets its own
split when the terminal is shown, and toggle/close act on the current
tab's window only.
Each Neovim tabpage now owns its Claude sessions. Picker, CLI commands,
selection routing, and the websocket handshake all scope to the current
tab. Closing a tab disposes its sessions.

- New claudecode.tab_registry tracks session_id <-> tabpage. A tab can
  own many sessions; each session belongs to exactly one tab.
- session.get_active_session_id derives from the registry, with the
  pre-existing global as fallback for legacy state.
- destroy_session unbinds the session from the registry so freshly
  created sessions don't see stale slots.
- session.find_unbound_session is the new handshake target: the server
  binds an incoming Claude CLI client to the most recently created
  session whose client_id is nil, instead of "active session". Combined
  with binding the tab before termopen, this closes the race where
  switching tabs between spawn and handshake would route the new
  client to the wrong session.
- terminal.lua: every entry path (open, simple_toggle, focus_toggle,
  ensure_terminal_visible_no_focus, open_new_session) routes through
  provider.open_session for the current tab's session, creating and
  binding a new session if absent. switch_to_session re-binds the
  current tab so picker selections are sticky. Legacy provider path
  (no open_session) preserved for custom providers and test mocks.
- list_sessions_for_current_tab filters strictly by registry binding.
  Picker, tabbar, snacks renderers, and the close_session fallback all
  use it. No "owner == nil" fallthrough so unbound sessions never leak
  across tabs.
- TabClosed autocmd prunes the registry and destroys orphaned sessions.
- Worktree cwd fix: terminal spawn now falls back to vim.fn.getcwd()
  so :tcd in a tabpage actually reaches Claude. termopen({cwd=nil})
  inherits Neovim's process cwd which ignores :tcd / :lcd.

Adds 47 new unit tests covering registry semantics, derived active
session, find_unbound_session ordering, and tab-scoped list filter.
The tabbar was a module-level singleton designed for single-tab UX.
With per-tab terminals it leaked state across tabs (mouse needed two
clicks, sessions sometimes spawned unexpectedly, render landed on the
wrong tab). Full rewrite to one tabbar per tabpage.

- state.tabs[tabpage] = { tabbar_win, tabbar_buf, terminal_win,
  click_regions, winbar_session_ids } instead of a single shared blob.
- Drop the global vim.on_key mouse hook. Buffer-local <LeftMouse> /
  <MiddleMouse> / <ScrollWheelUp/Down> mappings on each tab's tabbar
  buffer fire exactly once per click, no press+release double-fire,
  no whole-nvim interception.
- Float created with focusable = false so the first click no longer
  steals focus from the terminal. After a switch action, refocus the
  terminal explicitly so typing into Claude is immediate.
- Per-tab winbar_session_ids; _G.ClaudeCodeTabClick reads current
  tabpage at click time so winbars in different tabs route to their
  own session lists.
- Autocmds (WinResized / WinScrolled / User session events / WinClosed)
  iterate state.tabs and render each entry against its own
  terminal_win, so a session event in tab 2 no longer paints tab 1's
  tabbar.
- TabEnter re-attach: gT into a tab whose terminal is already visible
  but lost its tabbar reattaches automatically; no need to toggle the
  terminal.
- Per-tab cleanup paths plus cleanup_all for plugin reload.

Public API unchanged from callers' POV (attach takes (winid, bufnr);
the tab is resolved via nvim_win_get_tabpage). Adds 11 new tests
covering per-tab attach / detach / cleanup and snapshot isolation.
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.

6 participants