diff --git a/README.md b/README.md index dd4e5a2..3f9d9f5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ without leaving your editor. - 📦 **Pre-configured for Popular Tools**: Out-of-the-box support for Claude, Gemini, Grok, Codex, Copilot CLI, and more. - ✨ **Context-Aware Prompts**: Automatically include file content, cursor position, and diagnostics in your prompts. - 📝 **Prompt Library**: A library of pre-defined prompts for common tasks like explaining code, fixing issues, or writing tests. - - 🔄 **Session Persistence**: Keep your CLI sessions alive with `tmux` and `zellij` integration. + - 🔄 **Session Persistence**: Keep your CLI sessions alive with `tmux`, `zellij`, and `wezterm` integration. - 📂 **Automatic File Watching**: Automatically reloads files in Neovim when they are modified by AI tools. - **🔌 Extensible and Customizable** @@ -325,7 +325,7 @@ local defaults = { -- 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 -- split: when run inside a terminal multiplexer, new sessions will be created in a new split - -- NOTE: zellij only supports `terminal` + -- NOTE: zellij only supports `terminal`, wezterm only supports `split` create = "terminal", ---@type "terminal"|"window"|"split" split = { vertical = true, -- vertical or horizontal split @@ -930,14 +930,14 @@ Use them together for the complete experience! ### Terminal sessions not persisting? -Make sure you have tmux or zellij installed and enable the multiplexer: +Make sure you have tmux, zellij, or wezterm installed and enable the multiplexer: ```lua opts = { cli = { mux = { enabled = true, - backend = "tmux", -- or "zellij" + backend = "tmux", -- or "zellij" or "wezterm" }, }, } diff --git a/lua/sidekick/cli/session/init.lua b/lua/sidekick/cli/session/init.lua index 1c59892..a7a4f93 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 0000000..bac00b4 --- /dev/null +++ b/lua/sidekick/cli/session/wezterm.lua @@ -0,0 +1,239 @@ +local Config = require("sidekick.config") +local Util = require("sidekick.util") + +---@class sidekick.cli.muxer.WezTerm: sidekick.cli.Session +---@field wezterm_pane_id number +local M = {} +M.__index = M +M.priority = 70 -- Higher than tmux/zellij for backwards compatibility +M.external = false -- Only works from inside WezTerm + +--- Initialize WezTerm session, verify we're running inside WezTerm +function M:init() + if not vim.env.WEZTERM_PANE then + Util.warn("WezTerm backend requires running inside WezTerm") + return + end + + if vim.fn.executable("wezterm") ~= 1 then + Util.warn("wezterm executable not found in PATH") + return + end +end + +--- Start a new WezTerm split pane session +---@return sidekick.cli.terminal.Cmd? +function M:start() + if not vim.env.WEZTERM_PANE then + Util.error("Cannot start WezTerm session: not running inside WezTerm") + return + end + + -- WezTerm only supports split mode (not terminal or window modes) + if Config.cli.mux.create ~= "split" then + Util.warn({ + ("WezTerm does not support `opts.cli.mux.create = %q`."):format(Config.cli.mux.create), + ("Falling back to %q."):format("split"), + "Please update your config.", + }) + end + + -- Build command: wezterm cli split-pane --cwd [split options] -- + local cmd = { "wezterm", "cli", "split-pane", "--cwd", self.cwd } + + -- Add split direction (WezTerm: horizontal = left/right, vertical = top/bottom) + -- Note: In WezTerm, "horizontal" means the split line is horizontal (panes side-by-side) + if Config.cli.mux.split.vertical then + table.insert(cmd, "--bottom") -- Top-bottom split + else + table.insert(cmd, "--horizontal") -- Side-by-side split + end + + -- Add split size + local size = Config.cli.mux.split.size + if size > 0 and size <= 1 then + -- Percentage (0-1) + table.insert(cmd, "--percent") + table.insert(cmd, tostring(math.floor(size * 100))) + elseif size > 1 then + -- Absolute cells + table.insert(cmd, "--cells") + table.insert(cmd, tostring(math.floor(size))) + end + + -- Add command separator and tool command + table.insert(cmd, "--") + vim.list_extend(cmd, self.tool.cmd) + + -- Execute and capture pane_id + local output = Util.exec(cmd, { notify = true }) + if not output or #output == 0 then + Util.error("Failed to create WezTerm split pane") + return + end + + -- Parse pane_id (wezterm cli split-pane returns just the pane ID number) + self.wezterm_pane_id = tonumber(output[1]) + if not self.wezterm_pane_id then + Util.error(("Failed to parse pane ID from WezTerm output: %s"):format(output[1])) + return + end + + self.started = true + + -- Save state to track this as a sidekick-created session + Util.set_state(tostring(self.wezterm_pane_id), { tool = self.tool.name, cwd = self.cwd }) + + Util.info(("Started **%s** in WezTerm pane %d"):format(self.tool.name, self.wezterm_pane_id)) +end + +--- Send text to WezTerm pane +---@param text string +function M:send(text) + if not self.wezterm_pane_id then + Util.error("Cannot send text: no pane ID available") + return + end + + Util.exec({ + "wezterm", + "cli", + "send-text", + "--pane-id", + tostring(self.wezterm_pane_id), + "--no-paste", + text, + }, { notify = false }) +end + +--- Submit current input (send newline) +function M:submit() + if not self.wezterm_pane_id then + Util.error("Cannot submit: no pane ID available") + return + end + + Util.exec({ + "wezterm", + "cli", + "send-text", + "--pane-id", + tostring(self.wezterm_pane_id), + "--no-paste", + "\n", + }, { notify = false }) +end + +--- Get process ID for a given TTY device +---@param tty string TTY device path like "/dev/ttys000" +---@return integer? pid +local function get_pid_from_tty(tty) + if not tty then + return nil + end + + -- Extract tty name (e.g., "ttys000" from "/dev/ttys000") + local tty_name = tty:match("/dev/(.+)$") + if not tty_name then + return nil + end + + -- Use ps to find the process with this tty + local lines = Util.exec({ "ps", "-o", "pid=,tty=", "-a" }, { notify = false }) + if not lines then + return nil + end + + for _, line in ipairs(lines) do + local pid, line_tty = line:match("^%s*(%d+)%s+(%S+)") + if line_tty == tty_name and pid then + return tonumber(pid) + end + end + + return nil +end + +--- Check if the WezTerm pane still exists +---@return boolean +function M:is_running() + if not self.wezterm_pane_id then + return false + end + + -- List all panes and check if our pane_id exists + local output = Util.exec({ "wezterm", "cli", "list", "--format", "json" }, { notify = false }) + if not output then + return false + end + + local ok, panes = pcall(vim.json.decode, table.concat(output, "\n")) + if not ok or type(panes) ~= "table" then + return false + end + + for _, pane in ipairs(panes) do + if pane.pane_id == self.wezterm_pane_id then + return true + end + end + + return false +end + +--- List all active sidekick sessions in WezTerm panes +---@return sidekick.cli.session.State[] +function M.sessions() + -- Get all WezTerm panes + local output = Util.exec({ "wezterm", "cli", "list", "--format", "json" }, { notify = false }) + if not output then + return {} + end + + local ok, panes = pcall(vim.json.decode, table.concat(output, "\n")) + if not ok or type(panes) ~= "table" then + return {} + end + + local ret = {} ---@type sidekick.cli.session.State[] + local tools = Config.tools() + local Procs = require("sidekick.cli.procs") + local procs = Procs.new() + + -- Walk through each pane's processes + for _, pane in ipairs(panes) do + -- Only include panes that were created by sidekick + local state = Util.get_state(tostring(pane.pane_id)) + if not state then + goto continue + end + + local pid = get_pid_from_tty(pane.tty_name) + + if pid then + procs:walk(pid, function(proc) + for _, tool in pairs(tools) do + if tool:is_proc(proc) then + -- Parse cwd from file:// URL + local cwd = pane.cwd and pane.cwd:gsub("^file://", "") or proc.cwd + + ret[#ret + 1] = { + id = "wezterm:" .. pane.pane_id, + cwd = cwd, + tool = tool, + wezterm_pane_id = pane.pane_id, + pids = Procs.pids(pid), + } + return true + end + end + end) + end + + ::continue:: + end + + return ret +end + +return M diff --git a/lua/sidekick/config.lua b/lua/sidekick/config.lua index 0fb3b09..483a5b5 100644 --- a/lua/sidekick/config.lua +++ b/lua/sidekick/config.lua @@ -84,7 +84,7 @@ 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 enabled = false, @@ -224,7 +224,7 @@ 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.backend", { "tmux", "zellij", "wezterm" }) M.validate("cli.mux.create", { "terminal", "window", "split" }) end) end