Skip to content
Open
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
6 changes: 5 additions & 1 deletion lua/sidekick/cli/session/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,11 @@ function M.setup()
end
M.did_setup = true
Config.tools() -- load tools, since they may register session backends
local session_backends = { tmux = "sidekick.cli.session.tmux", zellij = "sidekick.cli.session.zellij" }
local session_backends = {
tmux = "sidekick.cli.session.tmux",
zellij = "sidekick.cli.session.zellij",
wezterm = "sidekick.cli.session.wezterm",
}
for name, mod in pairs(session_backends) do
if vim.fn.executable(name) == 1 then
M.register(name, require(mod))
Expand Down
245 changes: 245 additions & 0 deletions lua/sidekick/cli/session/wezterm.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
local Config = require("sidekick.config")
local Util = require("sidekick.util")

---@class sidekick.cli.muxer.WezTerm: sidekick.cli.Session
---@field wezterm_pane_id integer
---@field wezterm_pid integer
local M = {}
M.__index = M

function M:init()
if self.started then
self.external = true
else
self.external = vim.env.WEZTERM_PANE ~= nil and Config.cli.mux.create ~= "terminal"
if not self.external then
self.mux_session = self.sid
end
end
self.priority = self.external and 10 or 50
end

---Parse a WezTerm file URI ("file://hostname/path") into an absolute path.
---@param uri string?
---@return string?
function M.cwd_from_uri(uri)
if not uri then
return nil
end
local path = uri:match("^file://[^/]*(/.*)$")
return path and vim.fs.normalize(path) or uri
end

---Return the workspace name for the current WezTerm pane (via WEZTERM_PANE).
---@return string?
function M.current_workspace()
local pane_id = vim.env.WEZTERM_PANE
if not pane_id then
return nil
end
local _, output = Util.exec({ "wezterm", "cli", "list", "--format", "json" }, { notify = false })
if not output then
return nil
end
local ok, panes = pcall(vim.json.decode, output)
if not ok or type(panes) ~= "table" then
return nil
end
local id = tonumber(pane_id)
for _, pane in ipairs(panes) do
if pane.pane_id == id then
return pane.workspace
end
end
return nil
end

---Find the root (shell) PID for a TTY by looking for the process whose
---parent is not itself on that TTY.
---@param tty string full path like "/dev/ttys001"
---@return integer?
function M.root_pid(tty)
local tty_short = tty:match("[^/]+$")
if not tty_short then
return nil
end
local lines = Util.exec({ "ps", "-t", tty_short, "-o", "pid=,ppid=" }, { notify = false })
if not lines or #lines == 0 then
return nil
end
local pids = {} ---@type table<integer, integer> pid -> ppid
for _, line in ipairs(lines) do
local pid, ppid = line:match("^%s*(%d+)%s+(%d+)")
if pid then
pids[tonumber(pid)] = tonumber(ppid)
end
end
for pid, ppid in pairs(pids) do
if not pids[ppid] then
return pid
end
end
return (next(pids))
end

---Append env vars (via `env KEY=VAL`) and the tool command to cmd.
---WezTerm CLI has no -e flag, so we prepend `env` when needed.
---Entries with value==false are skipped (no unset support in wezterm CLI).
---@param cmd string[]
function M:add_cmd(cmd)
local has_env = false
for key, value in pairs(self.tool.env or {}) do
if value ~= false then
if not has_env then
cmd[#cmd + 1] = "env"
has_env = true
end
cmd[#cmd + 1] = ("%s=%s"):format(key, tostring(value))
end
end
vim.list_extend(cmd, self.tool.cmd)
end

---Run a wezterm CLI command and update session state with the returned pane ID.
---@param cmd string[]
function M:spawn(cmd)
local lines = Util.exec(cmd, { notify = true })
if not lines or #lines == 0 then
return
end
local pane_id = tonumber(lines[1]:match("^(%d+)"))
if pane_id then
self.id = "wezterm: " .. pane_id
self.wezterm_pane_id = pane_id
self.mux_session = tostring(pane_id)
self.started = true
end
end

---@return sidekick.cli.terminal.Cmd?
function M:start()
if not self.external then
return { cmd = self.tool.cmd, env = self.tool.env }
elseif Config.cli.mux.create == "window" then
local cmd = { "wezterm", "cli", "spawn", "--new-window", "--cwd", self.cwd }
local workspace = M.current_workspace()
if workspace then
vim.list_extend(cmd, { "--workspace", workspace })
end
self:add_cmd(cmd)
self:spawn(cmd)
Util.info(("Started **%s** in a new WezTerm window"):format(self.tool.name))
elseif Config.cli.mux.create == "tab" then
local cmd = { "wezterm", "cli", "spawn", "--cwd", self.cwd }
local pane_id = vim.env.WEZTERM_PANE
if pane_id then
vim.list_extend(cmd, { "--pane-id", pane_id })
end
self:add_cmd(cmd)
self:spawn(cmd)
Util.info(("Started **%s** in a new WezTerm tab"):format(self.tool.name))
elseif Config.cli.mux.create == "split" then
local cmd = { "wezterm", "cli", "split-pane", "--cwd", self.cwd }
cmd[#cmd + 1] = Config.cli.mux.split.vertical and "--right" or "--bottom"
local size = Config.cli.mux.split.size
vim.list_extend(cmd, { "--percent", tostring(math.floor(size <= 1 and size * 100 or size)) })
cmd[#cmd + 1] = "--"
self:add_cmd(cmd)
self:spawn(cmd)
Util.info(("Started **%s** in a new WezTerm split"):format(self.tool.name))
end
end

---@return sidekick.cli.terminal.Cmd?
function M:attach()
if not self.external then
return { cmd = self.tool.cmd, env = self.tool.env }
end
if self.wezterm_pane_id then
Util.exec(
{ "wezterm", "cli", "activate-pane", "--pane-id", tostring(self.wezterm_pane_id) },
{ notify = false }
)
end
end

function M:send(text)
Util.exec({
"wezterm", "cli", "send-text",
"--pane-id", tostring(self.wezterm_pane_id),
"--no-paste",
}, { stdin = text, notify = false })
end

function M:submit()
Util.exec({
"wezterm", "cli", "send-text",
"--pane-id", tostring(self.wezterm_pane_id),
"--no-paste",
"\r",
}, { notify = false })
end

function M:is_running()
if self.wezterm_pid then
return vim.api.nvim_get_proc(self.wezterm_pid) ~= nil
end
return self.wezterm_pane_id ~= nil
end

function M.sessions()
local _, output = Util.exec({ "wezterm", "cli", "list", "--format", "json" }, { notify = false })
if not output then
return {}
end
local ok, panes = pcall(vim.json.decode, output)
if not ok or type(panes) ~= "table" then
return {}
end

local tools = Config.tools()
local Procs = require("sidekick.cli.procs")
local procs = Procs.new()
local ret = {} ---@type sidekick.cli.session.State[]

for _, pane in ipairs(panes) do
if pane.tty_name then
local pid = M.root_pid(pane.tty_name)
if pid then
procs:walk(pid, function(proc)
for _, tool in pairs(tools) do
if tool:is_proc(proc) then
ret[#ret + 1] = {
id = "wezterm: " .. pane.pane_id,
cwd = proc.cwd or M.cwd_from_uri(pane.cwd),
tool = tool,
wezterm_pane_id = pane.pane_id,
wezterm_pid = pid,
mux_session = tostring(pane.pane_id),
pids = Procs.pids(pid),
}
return true
end
end
end)
end
end
end

return ret
end

function M:dump()
if not self.wezterm_pane_id then
return nil
end
local _, ret = Util.exec({
"wezterm", "cli", "get-text",
"--pane-id", tostring(self.wezterm_pane_id),
"--start-line", tostring(-Config.cli.mux.dump),
"--escapes",
}, { notify = false })
return ret
end

return M
13 changes: 7 additions & 6 deletions lua/sidekick/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,16 @@ local defaults = {
nav = nil,
},
---@class sidekick.cli.Mux
---@field backend? "tmux"|"zellij" Multiplexer backend to persist CLI sessions
---@field backend? "tmux"|"zellij"|"wezterm" Multiplexer backend to persist CLI sessions
mux = {
backend = vim.env.ZELLIJ and "zellij" or "tmux", -- default to tmux unless zellij is detected
backend = vim.env.ZELLIJ and "zellij" or (vim.env.TMUX and "tmux") or (vim.env.WEZTERM_PANE and "wezterm") or "tmux", -- zellij > tmux-in-mux > wezterm > tmux
enabled = false,
-- terminal: new sessions will be created for each CLI tool and shown in a Neovim terminal
-- window: when run inside a terminal multiplexer, new sessions will be created in a new tab
-- window: when run inside a terminal multiplexer, new sessions will be created in a new window
-- tab: when run inside a terminal multiplexer, new sessions will be created in a new tab (current window)
-- split: when run inside a terminal multiplexer, new sessions will be created in a new split
-- NOTE: zellij only supports `terminal`
create = "terminal", ---@type "terminal"|"window"|"split"
create = "terminal", ---@type "terminal"|"window"|"tab"|"split"
split = {
vertical = true, -- vertical or horizontal split
size = 0.5, -- size of the split (0-1 for percentage)
Expand Down Expand Up @@ -222,8 +223,8 @@ function M.setup(opts)
require("sidekick.status").setup()

M.validate("cli.win.layout", { "float", "left", "bottom", "top", "right" })
M.validate("cli.mux.backend", { "tmux", "zellij" })
M.validate("cli.mux.create", { "terminal", "window", "split" })
M.validate("cli.mux.backend", { "tmux", "zellij", "wezterm" })
M.validate("cli.mux.create", { "terminal", "window", "tab", "split" })
M.validate("nes.diff.show", { "always", "cursor" })
end)
end
Expand Down
Loading
Loading