Skip to content

Commit 8f8373b

Browse files
author
Snir Turgeman
committed
feat: add multi-session terminal support
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
1 parent 6091df0 commit 8f8373b

File tree

11 files changed

+2581
-22
lines changed

11 files changed

+2581
-22
lines changed

lua/claudecode/config.lua

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ function M.validate(config)
7979
end
8080
end
8181

82+
-- Validate terminal keymaps if present
83+
if config.terminal.keymaps then
84+
assert(type(config.terminal.keymaps) == "table", "terminal.keymaps must be a table")
85+
if config.terminal.keymaps.exit_terminal ~= nil then
86+
local exit_type = type(config.terminal.keymaps.exit_terminal)
87+
assert(
88+
exit_type == "string" or (exit_type == "boolean" and config.terminal.keymaps.exit_terminal == false),
89+
"terminal.keymaps.exit_terminal must be a string or false"
90+
)
91+
end
92+
end
93+
8294
local valid_log_levels = { "trace", "debug", "info", "warn", "error" }
8395
local is_valid_log_level = false
8496
for _, level in ipairs(valid_log_levels) do

lua/claudecode/init.lua

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,68 @@ function M._create_commands()
10201020
end, {
10211021
desc = "Close the Claude Code terminal window",
10221022
})
1023+
1024+
-- Multi-session commands
1025+
vim.api.nvim_create_user_command("ClaudeCodeNew", function(opts)
1026+
local cmd_args = opts.args and opts.args ~= "" and opts.args or nil
1027+
local session_id = terminal.open_new_session({}, cmd_args)
1028+
logger.info("command", "Created new Claude Code session: " .. session_id)
1029+
end, {
1030+
nargs = "*",
1031+
desc = "Create a new Claude Code terminal session",
1032+
})
1033+
1034+
vim.api.nvim_create_user_command("ClaudeCodeSessions", function()
1035+
M.show_session_picker()
1036+
end, {
1037+
desc = "Show Claude Code session picker",
1038+
})
1039+
1040+
vim.api.nvim_create_user_command("ClaudeCodeSwitch", function(opts)
1041+
local session_index = opts.args and tonumber(opts.args)
1042+
if not session_index then
1043+
logger.error("command", "ClaudeCodeSwitch requires a session number")
1044+
return
1045+
end
1046+
1047+
local sessions = terminal.list_sessions()
1048+
if session_index < 1 or session_index > #sessions then
1049+
logger.error("command", "Invalid session number: " .. session_index .. " (have " .. #sessions .. " sessions)")
1050+
return
1051+
end
1052+
1053+
terminal.switch_to_session(sessions[session_index].id)
1054+
logger.info("command", "Switched to session " .. session_index)
1055+
end, {
1056+
nargs = 1,
1057+
desc = "Switch to Claude Code session by number",
1058+
})
1059+
1060+
vim.api.nvim_create_user_command("ClaudeCodeCloseSession", function(opts)
1061+
local session_index = opts.args and opts.args ~= "" and tonumber(opts.args)
1062+
1063+
if session_index then
1064+
local sessions = terminal.list_sessions()
1065+
if session_index < 1 or session_index > #sessions then
1066+
logger.error("command", "Invalid session number: " .. session_index .. " (have " .. #sessions .. " sessions)")
1067+
return
1068+
end
1069+
terminal.close_session(sessions[session_index].id)
1070+
logger.info("command", "Closed session " .. session_index)
1071+
else
1072+
-- Close active session
1073+
local active_id = terminal.get_active_session_id()
1074+
if active_id then
1075+
terminal.close_session(active_id)
1076+
logger.info("command", "Closed active session")
1077+
else
1078+
logger.warn("command", "No active session to close")
1079+
end
1080+
end
1081+
end, {
1082+
nargs = "?",
1083+
desc = "Close a Claude Code session by number (or active session if no number)",
1084+
})
10231085
else
10241086
logger.error(
10251087
"init",
@@ -1080,6 +1142,95 @@ M.open_with_model = function(additional_args)
10801142
end)
10811143
end
10821144

1145+
---Show session picker UI for selecting between active sessions
1146+
function M.show_session_picker()
1147+
local terminal = require("claudecode.terminal")
1148+
local sessions = terminal.list_sessions()
1149+
1150+
if #sessions == 0 then
1151+
logger.warn("command", "No active Claude Code sessions")
1152+
return
1153+
end
1154+
1155+
local active_session_id = terminal.get_active_session_id()
1156+
1157+
-- Format session items for display
1158+
local items = {}
1159+
for i, session in ipairs(sessions) do
1160+
local age = math.floor((vim.loop.now() - session.created_at) / 1000 / 60)
1161+
local age_str
1162+
if age < 1 then
1163+
age_str = "just now"
1164+
elseif age == 1 then
1165+
age_str = "1 min ago"
1166+
else
1167+
age_str = age .. " mins ago"
1168+
end
1169+
1170+
local active_marker = session.id == active_session_id and " (active)" or ""
1171+
table.insert(items, {
1172+
index = i,
1173+
session = session,
1174+
display = string.format("[%d] %s - %s%s", i, session.name, age_str, active_marker),
1175+
})
1176+
end
1177+
1178+
-- Try to use available picker (Snacks, fzf-lua, or vim.ui.select)
1179+
local pick_ok = M._try_picker(items, function(item)
1180+
if item and item.session then
1181+
terminal.switch_to_session(item.session.id)
1182+
end
1183+
end)
1184+
1185+
if not pick_ok then
1186+
-- Fallback to vim.ui.select
1187+
vim.ui.select(items, {
1188+
prompt = "Select Claude Code session:",
1189+
format_item = function(item)
1190+
return item.display
1191+
end,
1192+
}, function(choice)
1193+
if choice and choice.session then
1194+
terminal.switch_to_session(choice.session.id)
1195+
end
1196+
end)
1197+
end
1198+
end
1199+
1200+
---Try to use an enhanced picker (fzf-lua)
1201+
---@param items table[] Items to pick from
1202+
---@param on_select function Callback when item is selected
1203+
---@return boolean success Whether an enhanced picker was used
1204+
function M._try_picker(items, on_select)
1205+
-- Try fzf-lua
1206+
local fzf_ok, fzf = pcall(require, "fzf-lua")
1207+
if fzf_ok and fzf then
1208+
local display_items = {}
1209+
local item_map = {}
1210+
for _, item in ipairs(items) do
1211+
table.insert(display_items, item.display)
1212+
item_map[item.display] = item
1213+
end
1214+
1215+
fzf.fzf_exec(display_items, {
1216+
prompt = "Claude Sessions> ",
1217+
actions = {
1218+
["default"] = function(selected)
1219+
if selected and selected[1] then
1220+
local item = item_map[selected[1]]
1221+
if item then
1222+
on_select(item)
1223+
end
1224+
end
1225+
end,
1226+
},
1227+
})
1228+
return true
1229+
end
1230+
1231+
return false
1232+
end
1233+
10831234
---Get version information
10841235
---@return { version: string, major: integer, minor: integer, patch: integer, prerelease: string|nil }
10851236
function M.get_version()

lua/claudecode/selection.lua

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
---Manages selection tracking and communication with the Claude server.
2+
---Supports session-aware selection tracking for multi-session environments.
23
---@module 'claudecode.selection'
34
local M = {}
45

56
local logger = require("claudecode.logger")
7+
local session_manager = require("claudecode.session")
68
local terminal = require("claudecode.terminal")
79

810
M.state = {
@@ -236,6 +238,13 @@ function M.update_selection()
236238

237239
if changed then
238240
M.state.latest_selection = current_selection
241+
242+
-- Also update the active session's selection state
243+
local active_session_id = session_manager.get_active_session_id()
244+
if active_session_id then
245+
session_manager.update_selection(active_session_id, current_selection)
246+
end
247+
239248
if M.server then
240249
M.send_selection_update(current_selection)
241250
end
@@ -538,8 +547,18 @@ function M.has_selection_changed(new_selection)
538547
end
539548

540549
---Sends the selection update to the Claude server.
550+
---Uses session-aware sending if available, otherwise broadcasts to all.
541551
---@param selection table The selection object to send.
542552
function M.send_selection_update(selection)
553+
-- Try to send to active session first
554+
if M.server.send_to_active_session then
555+
local sent = M.server.send_to_active_session("selection_changed", selection)
556+
if sent then
557+
return
558+
end
559+
end
560+
561+
-- Fallback to broadcast
543562
M.server.broadcast("selection_changed", selection)
544563
end
545564

@@ -549,6 +568,28 @@ function M.get_latest_selection()
549568
return M.state.latest_selection
550569
end
551570

571+
---Gets the selection for a specific session.
572+
---@param session_id string The session ID
573+
---@return table|nil The selection object for the session, or nil if none recorded.
574+
function M.get_session_selection(session_id)
575+
return session_manager.get_selection(session_id)
576+
end
577+
578+
---Gets the selection for the active session.
579+
---Falls back to global latest_selection if no session-specific selection.
580+
---@return table|nil The selection object, or nil if none recorded.
581+
function M.get_active_session_selection()
582+
local active_session_id = session_manager.get_active_session_id()
583+
if active_session_id then
584+
local session_selection = session_manager.get_selection(active_session_id)
585+
if session_selection then
586+
return session_selection
587+
end
588+
end
589+
-- Fallback to global selection
590+
return M.state.latest_selection
591+
end
592+
552593
---Sends the current selection to Claude.
553594
---This function is typically invoked by a user command. It forces an immediate
554595
---update and sends the latest selection.

lua/claudecode/server/init.lua

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---@brief WebSocket server for Claude Code Neovim integration
22
local claudecode_main = require("claudecode") -- Added for version access
33
local logger = require("claudecode.logger")
4+
local session_manager = require("claudecode.session")
45
local tcp_server = require("claudecode.server.tcp")
56
local tools = require("claudecode.tools.init") -- Added: Require the tools module
67

@@ -62,6 +63,16 @@ function M.start(config, auth_token)
6263
logger.debug("server", "WebSocket client connected (no auth):", client.id)
6364
end
6465

66+
-- Try to bind client to an available session (active session or first unbound session)
67+
local active_session_id = session_manager.get_active_session_id()
68+
if active_session_id then
69+
local active_session = session_manager.get_session(active_session_id)
70+
if active_session and not active_session.client_id then
71+
session_manager.bind_client(active_session_id, client.id)
72+
logger.debug("server", "Bound client", client.id, "to active session", active_session_id)
73+
end
74+
end
75+
6576
-- Notify main module about new connection for queue processing
6677
local main_module = require("claudecode")
6778
if main_module.process_mention_queue then
@@ -71,6 +82,9 @@ function M.start(config, auth_token)
7182
end
7283
end,
7384
on_disconnect = function(client, code, reason)
85+
-- Unbind client from session before removing
86+
session_manager.unbind_client(client.id)
87+
7488
M.state.clients[client.id] = nil
7589
logger.debug(
7690
"server",
@@ -402,6 +416,65 @@ function M.broadcast(method, params)
402416
return true
403417
end
404418

419+
---Send a message to a specific session's bound client
420+
---@param session_id string The session ID
421+
---@param method string The method name
422+
---@param params table|nil The parameters to send
423+
---@return boolean success Whether message was sent successfully
424+
function M.send_to_session(session_id, method, params)
425+
if not M.state.server then
426+
return false
427+
end
428+
429+
local session = session_manager.get_session(session_id)
430+
if not session or not session.client_id then
431+
logger.debug("server", "Cannot send to session", session_id, "- no bound client")
432+
return false
433+
end
434+
435+
local client = M.state.clients[session.client_id]
436+
if not client then
437+
logger.debug("server", "Cannot send to session", session_id, "- client not found")
438+
return false
439+
end
440+
441+
return M.send(client, method, params)
442+
end
443+
444+
---Send a message to the active session's bound client
445+
---@param method string The method name
446+
---@param params table|nil The parameters to send
447+
---@return boolean success Whether message was sent successfully
448+
function M.send_to_active_session(method, params)
449+
local active_session_id = session_manager.get_active_session_id()
450+
if not active_session_id then
451+
-- Fallback to broadcast if no active session
452+
logger.debug("server", "No active session, falling back to broadcast")
453+
return M.broadcast(method, params)
454+
end
455+
456+
return M.send_to_session(active_session_id, method, params)
457+
end
458+
459+
---Get the session ID for a client
460+
---@param client_id string The client ID
461+
---@return string|nil session_id The session ID or nil
462+
function M.get_client_session(client_id)
463+
local session = session_manager.find_session_by_client(client_id)
464+
if session then
465+
return session.id
466+
end
467+
return nil
468+
end
469+
470+
---Bind a client to a session
471+
---@param client_id string The client ID
472+
---@param session_id string The session ID
473+
---@return boolean success Whether binding was successful
474+
function M.bind_client_to_session(client_id, session_id)
475+
return session_manager.bind_client(session_id, client_id)
476+
end
477+
405478
---Get server status information
406479
---@return table status Server status information
407480
function M.get_status()

0 commit comments

Comments
 (0)