From 90133864f92e74bffd36e6685c244f3184e2d218 Mon Sep 17 00:00:00 2001 From: James Hicks Date: Thu, 28 May 2026 18:03:33 -0500 Subject: [PATCH] feat(mux): Add wezterm as a mux backend Supports opening in a split, tab or new window Note: - focus / toggle isn't implement but this is parity with existig backends tmux and zellij --- lua/sidekick/cli/session/init.lua | 6 +- lua/sidekick/cli/session/wezterm.lua | 245 ++++++++++++++++ lua/sidekick/config.lua | 13 +- tests/wezterm_spec.lua | 403 +++++++++++++++++++++++++++ 4 files changed, 660 insertions(+), 7 deletions(-) create mode 100644 lua/sidekick/cli/session/wezterm.lua create mode 100644 tests/wezterm_spec.lua diff --git a/lua/sidekick/cli/session/init.lua b/lua/sidekick/cli/session/init.lua index 1c598924..a7a4f93d 100644 --- a/lua/sidekick/cli/session/init.lua +++ b/lua/sidekick/cli/session/init.lua @@ -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)) diff --git a/lua/sidekick/cli/session/wezterm.lua b/lua/sidekick/cli/session/wezterm.lua new file mode 100644 index 00000000..dedc5cc7 --- /dev/null +++ b/lua/sidekick/cli/session/wezterm.lua @@ -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 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 diff --git a/lua/sidekick/config.lua b/lua/sidekick/config.lua index d30e92e8..b55489ec 100644 --- a/lua/sidekick/config.lua +++ b/lua/sidekick/config.lua @@ -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) @@ -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 diff --git a/tests/wezterm_spec.lua b/tests/wezterm_spec.lua new file mode 100644 index 00000000..932998a1 --- /dev/null +++ b/tests/wezterm_spec.lua @@ -0,0 +1,403 @@ +---@module 'luassert' + +local WezTerm = require("sidekick.cli.session.wezterm") +local Config = require("sidekick.config") +local Util = require("sidekick.util") +local Session = require("sidekick.cli.session") + +-- Minimal tool stub for building session objects +local function make_tool(opts) + opts = opts or {} + return { + name = opts.name or "claude", + cmd = opts.cmd or { "claude" }, + env = opts.env or {}, + config = { env = {} }, + keys = {}, + is_proc = function() return false end, + clone = function(self, o) + return vim.tbl_extend("force", self, o or {}) + end, + } +end + +-- Build a session object directly without going through Session.new +-- (avoids needing a full Config setup for most unit tests) +local function make_session(opts) + opts = opts or {} + local tool = opts.tool or make_tool() + local self = setmetatable({ + tool = tool, + cwd = opts.cwd or "/home/user/project", + sid = opts.sid or "claude abc123456789", + id = opts.id, + backend = "wezterm", + started = opts.started or false, + mux_session = opts.mux_session, + wezterm_pane_id = opts.wezterm_pane_id, + wezterm_pid = opts.wezterm_pid, + external = opts.external, + }, WezTerm) + self.id = self.id or self.sid + return self +end + +-- ── cwd_from_uri ──────────────────────────────────────────────────────────── + +describe("WezTerm.cwd_from_uri", function() + local cases = { + { "file://hostname.local/Users/james/projects/foo", "/Users/james/projects/foo" }, + { "file://dsl-lt-1124.local/Users/james/projects/sidekick.nvim", "/Users/james/projects/sidekick.nvim" }, + { "file:///home/user/project", "/home/user/project" }, + { "/already/absolute", "/already/absolute" }, + { nil, nil }, + } + + for _, case in ipairs(cases) do + it(("parses %s"):format(vim.inspect(case[1])), function() + assert.are.same(case[2], WezTerm.cwd_from_uri(case[1])) + end) + end +end) + +-- ── root_pid ───────────────────────────────────────────────────────────────── + +describe("WezTerm.root_pid", function() + local original_exec + + before_each(function() + original_exec = Util.exec + end) + + after_each(function() + Util.exec = original_exec + end) + + it("returns the process whose ppid is not in the set", function() + -- Shell (1234) -> claude (5678): root is 1234 + Util.exec = function(cmd, _) + if cmd[1] == "ps" then + return { " 1234 900", " 5678 1234" }, " 1234 900\n 5678 1234\n" + end + end + assert.are.same(1234, WezTerm.root_pid("/dev/ttys001")) + end) + + it("returns a pid when only one process is on the tty", function() + Util.exec = function(cmd, _) + if cmd[1] == "ps" then + return { " 9999 1" }, " 9999 1\n" + end + end + local pid = WezTerm.root_pid("/dev/ttys042") + assert.is_not_nil(pid) + end) + + it("returns nil when ps returns nothing", function() + Util.exec = function(_, _) + return nil + end + assert.is_nil(WezTerm.root_pid("/dev/ttys001")) + end) + + it("passes the short tty name (without /dev/) to ps", function() + local captured_cmd + Util.exec = function(cmd, _) + captured_cmd = cmd + return { " 1 0" }, " 1 0\n" + end + WezTerm.root_pid("/dev/ttys007") + -- The -t argument should be the short form + local t_idx + for i, v in ipairs(captured_cmd) do + if v == "-t" then t_idx = i end + end + assert.is_not_nil(t_idx) + assert.are.same("ttys007", captured_cmd[t_idx + 1]) + end) +end) + +-- ── add_cmd ────────────────────────────────────────────────────────────────── + +describe("WezTerm session:add_cmd", function() + it("appends tool.cmd when there are no env vars", function() + local s = make_session({ tool = make_tool({ cmd = { "claude", "--no-auto-accept" } }) }) + local cmd = { "wezterm", "cli", "spawn" } + s:add_cmd(cmd) + assert.are.same({ "wezterm", "cli", "spawn", "claude", "--no-auto-accept" }, cmd) + end) + + it("prepends 'env KEY=VAL' for each env var that is not false", function() + local s = make_session({ + tool = make_tool({ + cmd = { "claude" }, + env = { FOO = "bar", SKIP = false }, + }), + }) + local cmd = { "wezterm", "cli", "spawn" } + s:add_cmd(cmd) + -- 'env' prefix should appear + assert.are.same("env", cmd[4]) + -- tool cmd should come after env vars + assert.are.same("claude", cmd[#cmd]) + -- false values should not appear + for _, v in ipairs(cmd) do + assert.is_not.equal("SKIP=false", v) + assert.is_not.equal("false", v) + end + end) + + it("does not prepend 'env' when all env vars are false", function() + local s = make_session({ + tool = make_tool({ cmd = { "claude" }, env = { REMOVE_ME = false } }), + }) + local cmd = {} + s:add_cmd(cmd) + assert.are.same({ "claude" }, cmd) + end) +end) + +-- ── spawn ──────────────────────────────────────────────────────────────────── + +describe("WezTerm session:spawn", function() + local original_exec + + before_each(function() + original_exec = Util.exec + end) + + after_each(function() + Util.exec = original_exec + end) + + it("sets wezterm_pane_id, id, mux_session and started from CLI output", function() + Util.exec = function(_, _) + return { "42" }, "42\n" + end + local s = make_session() + s:spawn({ "wezterm", "cli", "spawn" }) + assert.are.same(42, s.wezterm_pane_id) + assert.are.same("wezterm: 42", s.id) + assert.are.same("42", s.mux_session) + assert.is_true(s.started) + end) + + it("does nothing when the command fails", function() + Util.exec = function(_, _) + return nil + end + local s = make_session() + s:spawn({ "wezterm", "cli", "spawn" }) + assert.is_nil(s.wezterm_pane_id) + assert.is_false(s.started) + end) +end) + +-- ── init ───────────────────────────────────────────────────────────────────── + +describe("WezTerm session:init", function() + local original_create + + before_each(function() + original_create = Config.cli.mux.create + end) + + after_each(function() + Config.cli.mux.create = original_create + vim.env.WEZTERM_PANE = nil + end) + + it("external=true for discovered (started) sessions", function() + local s = make_session({ started = true }) + s:init() + assert.is_true(s.external) + assert.are.same(10, s.priority) + end) + + it("external=true when in WezTerm and create=window", function() + vim.env.WEZTERM_PANE = "5" + Config.cli.mux.create = "window" + local s = make_session({ started = false }) + s:init() + assert.is_true(s.external) + end) + + it("external=true when in WezTerm and create=split", function() + vim.env.WEZTERM_PANE = "5" + Config.cli.mux.create = "split" + local s = make_session({ started = false }) + s:init() + assert.is_true(s.external) + end) + + it("external=true when in WezTerm and create=tab", function() + vim.env.WEZTERM_PANE = "5" + Config.cli.mux.create = "tab" + local s = make_session({ started = false }) + s:init() + assert.is_true(s.external) + end) + + it("external=false when create=terminal", function() + vim.env.WEZTERM_PANE = "5" + Config.cli.mux.create = "terminal" + local s = make_session({ started = false }) + s:init() + assert.is_false(s.external) + assert.are.same(50, s.priority) + end) + + it("external=false when not in WezTerm", function() + vim.env.WEZTERM_PANE = nil + Config.cli.mux.create = "window" + local s = make_session({ started = false }) + s:init() + assert.is_false(s.external) + end) + + it("sets mux_session=sid for non-external sessions", function() + vim.env.WEZTERM_PANE = nil + Config.cli.mux.create = "terminal" + local s = make_session({ started = false }) + s:init() + assert.are.same(s.sid, s.mux_session) + end) +end) + +-- ── is_running ─────────────────────────────────────────────────────────────── + +describe("WezTerm session:is_running", function() + it("returns true when wezterm_pid process exists", function() + local s = make_session({ wezterm_pid = vim.fn.getpid() }) -- current nvim pid + assert.is_true(s:is_running()) + end) + + it("returns false when wezterm_pid process does not exist", function() + local s = make_session({ wezterm_pid = 9999999 }) + assert.is_false(s:is_running()) + end) + + it("returns true when no wezterm_pid but wezterm_pane_id is set (just spawned)", function() + local s = make_session({ wezterm_pane_id = 7 }) + assert.is_true(s:is_running()) + end) + + it("returns false when neither wezterm_pid nor wezterm_pane_id is set", function() + local s = make_session() + assert.is_false(s:is_running()) + end) +end) + +-- ── current_workspace ──────────────────────────────────────────────────────── + +describe("WezTerm.current_workspace", function() + local original_exec + local original_pane + + before_each(function() + original_exec = Util.exec + original_pane = vim.env.WEZTERM_PANE + end) + + after_each(function() + Util.exec = original_exec + vim.env.WEZTERM_PANE = original_pane + end) + + it("returns the workspace name for the current pane", function() + vim.env.WEZTERM_PANE = "5" + Util.exec = function(_, _) + local json = vim.json.encode({ + { pane_id = 5, workspace = "mywork" }, + { pane_id = 6, workspace = "other" }, + }) + return {}, json + end + assert.are.same("mywork", WezTerm.current_workspace()) + end) + + it("returns nil when WEZTERM_PANE is not set", function() + vim.env.WEZTERM_PANE = nil + assert.is_nil(WezTerm.current_workspace()) + end) + + it("returns nil when the pane is not found in the list", function() + vim.env.WEZTERM_PANE = "99" + Util.exec = function(_, _) + return {}, vim.json.encode({ { pane_id = 5, workspace = "mywork" } }) + end + assert.is_nil(WezTerm.current_workspace()) + end) + + it("returns nil when exec fails", function() + vim.env.WEZTERM_PANE = "5" + Util.exec = function(_, _) + return nil + end + assert.is_nil(WezTerm.current_workspace()) + end) +end) + +-- ── start (tab) ────────────────────────────────────────────────────────────── + +describe("WezTerm session:start (tab)", function() + local original_exec + local original_create + local original_pane + local spawned_cmd + + before_each(function() + original_exec = Util.exec + original_create = Config.cli.mux.create + original_pane = vim.env.WEZTERM_PANE + Config.cli.mux.create = "tab" + vim.env.WEZTERM_PANE = "3" + spawned_cmd = nil + Util.exec = function(cmd, _) + spawned_cmd = vim.deepcopy(cmd) + return { "7" }, "7\n" + end + end) + + after_each(function() + Util.exec = original_exec + Config.cli.mux.create = original_create + vim.env.WEZTERM_PANE = original_pane + end) + + it("spawns without --new-window", function() + local s = make_session({ external = true }) + s:start() + assert.is_not_nil(spawned_cmd) + for _, v in ipairs(spawned_cmd) do + assert.is_not.equal("--new-window", v) + end + end) + + it("includes --pane-id with the WEZTERM_PANE value", function() + local s = make_session({ external = true }) + s:start() + local found = false + for i, v in ipairs(spawned_cmd) do + if v == "--pane-id" and spawned_cmd[i + 1] == "3" then + found = true + end + end + assert.is_true(found) + end) + + it("omits --pane-id when WEZTERM_PANE is not set", function() + vim.env.WEZTERM_PANE = nil + local s = make_session({ external = true }) + s:start() + for _, v in ipairs(spawned_cmd) do + assert.is_not.equal("--pane-id", v) + end + end) + + it("returns nil (does not request a terminal cmd)", function() + local s = make_session({ external = true }) + local result = s:start() + assert.is_nil(result) + end) +end)