diff --git a/lua/opencode/provider/snacks.lua b/lua/opencode/provider/snacks.lua index 683ba5a5..de5c10c1 100644 --- a/lua/opencode/provider/snacks.lua +++ b/lua/opencode/provider/snacks.lua @@ -4,6 +4,7 @@ ---@class opencode.provider.Snacks : opencode.Provider --- ---@field opts snacks.terminal.Opts +---@field private _pid number|nil local Snacks = {} Snacks.__index = Snacks Snacks.name = "snacks" @@ -15,6 +16,30 @@ Snacks.name = "snacks" function Snacks.new(opts) local self = setmetatable({}, Snacks) self.opts = opts or {} + self._pid = nil + + -- Hook into on_buf to capture the terminal job's PID when the buffer is created. + -- We must capture the PID eagerly at startup because by the time VimLeavePre fires + -- and stop() is called, the terminal job has been cleared and terminal_job_id is no + -- longer available. This also ensures PID capture happens automatically regardless + -- of how the terminal is started (toggle, open), without duplicating the logic. + self.opts.win = self.opts.win or {} + local user_on_buf = self.opts.win.on_buf + self.opts.win.on_buf = function(win) + if user_on_buf then + user_on_buf(win) + end + ---@diagnostic disable: invisible -- accessing private fields from closure within constructor + vim.api.nvim_create_autocmd("TermOpen", { + buffer = win.buf, + once = true, + callback = function() + self:get_pid() + end, + }) + ---@diagnostic enable: invisible + end + return self end @@ -54,12 +79,30 @@ function Snacks:start() end function Snacks:stop() + require("opencode.provider.util").kill(self:get_pid()) + self._pid = nil + local win = self:get() if win then - -- TODO: Stop the job first so we don't get error exit code. - -- Not sure how to get the job ID from snacks API though. win:close() end end +---Capture and cache the PID of the terminal job. +---@return number? +function Snacks:get_pid() + local buf = self:get() and self:get().buf + if not self._pid and buf then + local job_id = vim.b[buf].terminal_job_id + if job_id then + local ok, pid = pcall(vim.fn.jobpid, job_id) + if ok then + self._pid = pid + end + end + end + + return self._pid +end + return Snacks diff --git a/lua/opencode/provider/terminal.lua b/lua/opencode/provider/terminal.lua index 8b1b61c6..e7a3cbf7 100644 --- a/lua/opencode/provider/terminal.lua +++ b/lua/opencode/provider/terminal.lua @@ -5,6 +5,7 @@ --- ---@field bufnr? integer ---@field winid? integer +---@field private _pid number|nil local Terminal = {} Terminal.__index = Terminal Terminal.name = "terminal" @@ -16,6 +17,7 @@ function Terminal.new(opts) self.opts = opts or {} self.winid = nil self.bufnr = nil + self._pid = nil return self end @@ -71,12 +73,17 @@ function Terminal:start() once = true, callback = function(event) require("opencode.keymaps").apply(event.buf) + -- Cache PID eagerly at terminal open time because by the time VimLeavePre fires + -- and stop() is called, the terminal job has been cleared and terminal_job_id + -- is no longer available. + self:get_pid() end, }) vim.fn.jobstart(self.cmd, { term = true, on_exit = function() + self._pid = nil self.winid = nil self.bufnr = nil end, @@ -86,15 +93,40 @@ function Terminal:start() end end ----Close the window, delete the buffer. function Terminal:stop() + local job_id = vim.b[self.bufnr].terminal_job_id + if job_id then + -- Apparently we still have to do this ourselves when Neovim *isn't* stopping. + -- Not needed for snacks.terminal - I guess it does it internally? + vim.fn.jobstop(job_id) + end + require("opencode.provider.util").kill(self:get_pid()) + self._pid = nil + if self.winid ~= nil and vim.api.nvim_win_is_valid(self.winid) then vim.api.nvim_win_close(self.winid, true) self.winid = nil end if self.bufnr ~= nil and vim.api.nvim_buf_is_valid(self.bufnr) then vim.api.nvim_buf_delete(self.bufnr, { force = true }) + self.bufnr = nil end end +---Capture and cache the PID of the terminal job. +---@return number? +function Terminal:get_pid() + if not self._pid and self.bufnr then + local job_id = vim.b[self.bufnr].terminal_job_id + if job_id then + local ok, pid = pcall(vim.fn.jobpid, job_id) + if ok then + self._pid = pid + end + end + end + + return self._pid +end + return Terminal diff --git a/lua/opencode/provider/tmux.lua b/lua/opencode/provider/tmux.lua index 747217c1..dcd88799 100644 --- a/lua/opencode/provider/tmux.lua +++ b/lua/opencode/provider/tmux.lua @@ -99,6 +99,7 @@ function Tmux:start() string.format("tmux split-window %s -P -F '#{pane_id}' %s '%s'", detach_flag, self.opts.options or "", self.cmd) ) ) + local disable_passthrough = self.opts.allow_passthrough ~= true -- default true (disable passthrough) if disable_passthrough and self.pane_id and self.pane_id ~= "" then vim.fn.system(string.format("tmux set-option -t %s -p allow-passthrough off", self.pane_id)) @@ -108,12 +109,20 @@ end ---Kill the `opencode` pane. function Tmux:stop() - local pane_id = self:get_pane_id() - if pane_id then - -- HACK: https://github.com/nickjvandyke/opencode.nvim/issues/118 - vim.fn.system("tmux send-keys -t " .. pane_id .. " C-c") - self.pane_id = nil + local pid = self:get_pid() + if pid then + require("opencode.provider.util").kill(pid) end end +---Capture the PID of the process running in the pane. +---@return number? +function Tmux:get_pid() + if not self.pane_id then + return nil + end + + return tonumber(vim.trim(vim.fn.system("tmux display-message -p -t " .. self.pane_id .. " '#{pane_pid}'"))) +end + return Tmux diff --git a/lua/opencode/provider/util.lua b/lua/opencode/provider/util.lua new file mode 100644 index 00000000..f825cbf8 --- /dev/null +++ b/lua/opencode/provider/util.lua @@ -0,0 +1,25 @@ +local M = {} + +---Terminate the process and its children reliably. +---HACK: for upstream issue described in https://github.com/anomalyco/opencode/issues/13001. +--- +---Uses the cached PID to kill the entire process group, which is more reliable +---than jobstop during VimLeavePre because: +--- 1. os.execute is synchronous (vim.fn.system spawns a job that Neovim kills during shutdown) +--- 2. Negative PID sends SIGTERM to the entire process group (children included) +--- 3. jobstop sends SIGHUP which can cause the process to daemonize/respawn +---@param pid number|nil The cached process ID +---@return boolean killed Whether the process was successfully terminated +function M.kill(pid) + if not pid then + return false + end + + if vim.fn.has("unix") == 1 then + return os.execute("kill -TERM -" .. pid .. " 2>/dev/null") ~= nil + else + return pcall(vim.uv.kill, pid, "sigterm") + end +end + +return M