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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
},
},
}
Expand Down
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
239 changes: 239 additions & 0 deletions lua/sidekick/cli/session/wezterm.lua
Original file line number Diff line number Diff line change
@@ -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 <cwd> [split options] -- <tool.cmd>
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
4 changes: 2 additions & 2 deletions lua/sidekick/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down