Skip to content

feat: implement bufhidden=hide for native terminal toggle #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 112 additions & 25 deletions lua/claudecode/terminal/native.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
--- @type TerminalProvider
local M = {}

local logger = require("claudecode.logger")

local bufnr = nil
local winid = nil
local jobid = nil
Expand Down Expand Up @@ -31,13 +33,12 @@ local function is_valid()
if vim.api.nvim_win_get_buf(win) == bufnr then
-- Found a window displaying our terminal buffer, update the tracked window ID
winid = win
require("claudecode.logger").debug("terminal", "Recovered terminal window ID:", win)
logger.debug("terminal", "Recovered terminal window ID:", win)
return true
end
end
-- Buffer exists but no window displays it
cleanup_state()
return false
-- Buffer exists but no window displays it - this is normal for hidden terminals
return true -- Buffer is valid even though not visible
end

-- Both buffer and window are valid
Expand Down Expand Up @@ -82,6 +83,8 @@ local function open_terminal(cmd_string, env_table, effective_config)
on_exit = function(job_id, _, _)
vim.schedule(function()
if job_id == jobid then
logger.debug("terminal", "Terminal process exited, cleaning up")

-- Ensure we are operating on the correct window and buffer before closing
local current_winid_for_job = winid
local current_bufnr_for_job = bufnr
Expand Down Expand Up @@ -135,7 +138,7 @@ local function close_terminal()
-- If the job already exited, on_exit would have cleaned up.
-- This direct close is for user-initiated close.
vim.api.nvim_win_close(winid, true)
cleanup_state() -- Ensure cleanup if on_exit doesn't fire (e.g. job already dead)
cleanup_state() -- Cleanup after explicit close
end
end

Expand All @@ -146,6 +149,78 @@ local function focus_terminal()
end
end

local function is_terminal_visible()
-- Check if our terminal buffer exists and is displayed in any window
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
return false
end

local windows = vim.api.nvim_list_wins()
for _, win in ipairs(windows) do
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then
-- Update our tracked window ID if we find the buffer in a different window
winid = win
return true
end
end

-- Buffer exists but no window displays it
winid = nil
return false
end

local function hide_terminal()
-- Hide the terminal window but keep the buffer and job alive
if bufnr and vim.api.nvim_buf_is_valid(bufnr) and winid and vim.api.nvim_win_is_valid(winid) then
-- Set buffer to hide instead of being wiped when window closes
vim.api.nvim_buf_set_option(bufnr, "bufhidden", "hide")

-- Close the window - this preserves the buffer and job
vim.api.nvim_win_close(winid, false)
winid = nil -- Clear window reference

logger.debug("terminal", "Terminal window hidden, process preserved")
end
end

local function show_hidden_terminal(effective_config)
-- Show an existing hidden terminal buffer in a new window
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
return false
end

-- Check if it's already visible
if is_terminal_visible() then
focus_terminal()
return true
end

-- Create a new window for the existing buffer
local width = math.floor(vim.o.columns * effective_config.split_width_percentage)
local full_height = vim.o.lines
local placement_modifier

if effective_config.split_side == "left" then
placement_modifier = "topleft "
else
placement_modifier = "botright "
end

vim.cmd(placement_modifier .. width .. "vsplit")
local new_winid = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_height(new_winid, full_height)

-- Set the existing buffer in the new window
vim.api.nvim_win_set_buf(new_winid, bufnr)
winid = new_winid

vim.api.nvim_set_current_win(winid)
vim.cmd("startinsert")

logger.debug("terminal", "Showed hidden terminal in new window")
return true
end

local function find_existing_claude_terminal()
local buffers = vim.api.nvim_list_bufs()
for _, buf in ipairs(buffers) do
Expand All @@ -158,13 +233,7 @@ local function find_existing_claude_terminal()
local windows = vim.api.nvim_list_wins()
for _, win in ipairs(windows) do
if vim.api.nvim_win_get_buf(win) == buf then
require("claudecode.logger").debug(
"terminal",
"Found existing Claude terminal in buffer",
buf,
"window",
win
)
logger.debug("terminal", "Found existing Claude terminal in buffer", buf, "window", win)
return buf, win
end
end
Expand Down Expand Up @@ -193,7 +262,7 @@ function M.open(cmd_string, env_table, effective_config)
bufnr = existing_buf
winid = existing_win
-- Note: We can't recover the job ID easily, but it's less critical
require("claudecode.logger").debug("terminal", "Recovered existing Claude terminal")
logger.debug("terminal", "Recovered existing Claude terminal")
focus_terminal()
else
if not open_terminal(cmd_string, env_table, effective_config) then
Expand All @@ -211,32 +280,50 @@ end
--- @param env_table table
--- @param effective_config table
function M.toggle(cmd_string, env_table, effective_config)
if is_valid() then
local claude_term_neovim_win_id = winid
local current_neovim_win_id = vim.api.nvim_get_current_win()
-- Check if we have a valid terminal buffer (process running)
local has_buffer = bufnr and vim.api.nvim_buf_is_valid(bufnr)
local is_visible = has_buffer and is_terminal_visible()

if claude_term_neovim_win_id == current_neovim_win_id then
close_terminal()
if has_buffer then
-- Terminal process exists
if is_visible then
-- Terminal is visible - check if we're currently in it
local current_win_id = vim.api.nvim_get_current_win()
if winid == current_win_id then
-- We're in the terminal window, hide it (but keep process running)
hide_terminal()
else
-- Terminal is visible but we're not in it, focus it
focus_terminal()
end
else
focus_terminal() -- This already calls startinsert
-- Terminal process exists but is hidden, show it
if show_hidden_terminal(effective_config) then
logger.debug("terminal", "Showing hidden terminal")
else
logger.error("terminal", "Failed to show hidden terminal")
end
end
else
-- Check if there's an existing Claude terminal we lost track of
-- No terminal process exists, check if there's an existing one we lost track of
local existing_buf, existing_win = find_existing_claude_terminal()
if existing_buf and existing_win then
-- Recover the existing terminal
bufnr = existing_buf
winid = existing_win
require("claudecode.logger").debug("terminal", "Recovered existing Claude terminal in toggle")
logger.debug("terminal", "Recovered existing Claude terminal")

-- Check if we're currently in this terminal
local current_neovim_win_id = vim.api.nvim_get_current_win()
if existing_win == current_neovim_win_id then
close_terminal()
-- Check if we're currently in this recovered terminal
local current_win_id = vim.api.nvim_get_current_win()
if existing_win == current_win_id then
-- We're in the recovered terminal, hide it
hide_terminal()
else
-- Focus the recovered terminal
focus_terminal()
end
else
-- No existing terminal found, create a new one
if not open_terminal(cmd_string, env_table, effective_config) then
vim.notify("Failed to open Claude terminal using native fallback (toggle).", vim.log.levels.ERROR)
end
Expand Down
Loading