Skip to content
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
47 changes: 45 additions & 2 deletions lua/opencode/provider/snacks.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -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
34 changes: 33 additions & 1 deletion lua/opencode/provider/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
---
---@field bufnr? integer
---@field winid? integer
---@field private _pid number|nil
local Terminal = {}
Terminal.__index = Terminal
Terminal.name = "terminal"
Expand All @@ -16,6 +17,7 @@ function Terminal.new(opts)
self.opts = opts or {}
self.winid = nil
self.bufnr = nil
self._pid = nil
return self
end

Expand Down Expand Up @@ -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,
Expand All @@ -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
19 changes: 14 additions & 5 deletions lua/opencode/provider/tmux.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
25 changes: 25 additions & 0 deletions lua/opencode/provider/util.lua
Original file line number Diff line number Diff line change
@@ -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