From fddf4204ecfcb3e620db162edfc8ba898a658646 Mon Sep 17 00:00:00 2001 From: cultab <51170833+cultab@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:24:52 +0000 Subject: [PATCH 1/4] docs(vimdoc):auto generate --- doc/command.nvim.txt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/doc/command.nvim.txt b/doc/command.nvim.txt index b62a2ab..233da9f 100644 --- a/doc/command.nvim.txt +++ b/doc/command.nvim.txt @@ -1,4 +1,4 @@ -*command.nvim.txt* For Neovim >= 0.9.0 Last change: 2025 October 20 +*command.nvim.txt* For Neovim >= 0.9.0 Last change: 2026 March 25 ============================================================================== Table of Contents *command.nvim-table-of-contents* @@ -36,7 +36,7 @@ if you want to make it executable. - |command.nvim-defaults| - |command.nvim-todo| -It supports the following multiplexers/terminal, refered to as backends +It supports the following multiplexers/terminal, referred to as backends hereafter: - tmux @@ -93,7 +93,7 @@ CONFIGURATION *command.nvim-toc-configuration* Configurations is done by setting the `vim.g.command` table. >lua - vim.g.command = {--[[ options go here ]]} ) + vim.g.command = {--[[ options go here ]]} < @@ -101,7 +101,7 @@ BACKENDS ~ The backend is the multiplexer or terminal to use. It’s controlled by the `backend` key in the options table. If the `backend` key is unset, as it is by -default, heuristics are used to pick on of the supported backends. +default, heuristics are used to pick one of the supported backends. - If `$TMUX` is set, tmux is used. - Else if `wezterm(?.exe)` exists in `$PATH`, wezterm is used. @@ -133,8 +133,8 @@ RULES ~ When using `Command File` to run a file, instead of simply running the file you might want to run a specific shell command. Using Rules you can, they are key-value pairs of lua patterns and functions. The lua pattern is compared -against the current filename, if it maches the function is run to get the shell -command and run it. +against the current filename, if it matches the function is run to get the +shell command and run it. >lua opts = { @@ -169,7 +169,7 @@ The defaults options are as follows: --- | 'tmux' --- | 'wezterm' --- | 'toggleterm' - use = nil + backend = nil --- defines rules to overwrite the command to run when using the "run current file" behavior --- keys are lua patterns (see :help lua-pattern) @@ -228,7 +228,7 @@ TODO *command.nvim-toc-todo* SIMILAR PLUGINS *command.nvim-toc-similar-plugins* -- yeet.nvim , very similar, would not have _originaly_ made this had I known yeet.nvim existed :^P +- yeet.nvim , very similar, would not have _originally_ made this had I known yeet.nvim existed :^P ============================================================================== 3. Links *command.nvim-links* From 087084ad8e67dc7e017744f27170a3a77e025de2 Mon Sep 17 00:00:00 2001 From: cultab Date: Thu, 26 Mar 2026 01:16:28 +0200 Subject: [PATCH 2/4] feat: add initial zellij backend support - Implemented zellij backend in lua/command/zellij.lua - Updated backend selection heuristics to recognize zellij - Updated documentation to list zellij as a supported backend Made-with: Cursor --- .vscode/settings.json | 5 + README.md | 13 +- doc/command.nvim.txt | 15 ++- lua/command/command-types.lua | 4 + lua/command/init.lua | 2 +- lua/command/zellij.lua | 233 ++++++++++++++++++++++++++++++++++ plugin/command.lua | 2 + 7 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 lua/command/zellij.lua diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..50f57ec --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "Lua.diagnostics.globals": [ + "vim" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index cf0ce55..d3f95a5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ It supports the following multiplexers/terminal, referred to as backends hereaft - tmux - wezterm +- zellij - toggleterm ## Installation @@ -80,7 +81,9 @@ If the `backend` key is unset, as it is by default, heuristics are used to pick * If `$TMUX` is set, tmux is used. -* Else if `wezterm(?.exe)` exists in `$PATH`, wezterm is used. +* Else if `$ZELLIJ` or `$ZELLIJ_SESSION_NAME` is set (Neovim is running inside Zellij), zellij is used. + +* Else if `$TERM` is `wezterm`, wezterm is used. * Else if toggleterm's module can be `require()`'ed, toggleterm is used. @@ -88,13 +91,14 @@ If the `backend` key is unset, as it is by default, heuristics are used to pick --- @alias backend_used --- | 'tmux' --- | 'wezterm' + --- | 'zellij' --- | 'toggleterm' backend = nil ``` -#### tmux & wezterm +#### tmux, wezterm, and zellij -The tmux and wezterm backends both have 2 built in pane directions, right of the editor pane, and below the editor pane. +The tmux, wezterm, and zellij backends each have 2 built in pane directions, right of the editor pane, and below the editor pane. #### ToggleTerm @@ -137,6 +141,7 @@ vim.g.command = { --- @alias backend_used --- | 'tmux' --- | 'wezterm' + --- | 'zellij' --- | 'toggleterm' backend = nil @@ -174,10 +179,10 @@ vim.g.command = { - [x] tmux - [x] ToggleTerm - [x] wezterm + - [x] zellij - [ ] default nvim terminal - [ ] kitty - [ ] some other backend - - [ ] zellij - [x] clean up types - [x] types for all opts, use lua-ls enums - [x] move to utils ? diff --git a/doc/command.nvim.txt b/doc/command.nvim.txt index 233da9f..1481341 100644 --- a/doc/command.nvim.txt +++ b/doc/command.nvim.txt @@ -41,6 +41,7 @@ hereafter: - tmux - wezterm +- zellij - toggleterm @@ -104,22 +105,25 @@ The backend is the multiplexer or terminal to use. It’s controlled by the default, heuristics are used to pick one of the supported backends. - If `$TMUX` is set, tmux is used. -- Else if `wezterm(?.exe)` exists in `$PATH`, wezterm is used. +- Else if `$ZELLIJ` or `$ZELLIJ_SESSION_NAME` is set (Neovim is running inside + Zellij), zellij is used. +- Else if `$TERM` is `wezterm`, wezterm is used. - Else if toggleterm’s module can be `require()`’ed, toggleterm is used. >lua --- @alias backend_used --- | 'tmux' --- | 'wezterm' + --- | 'zellij' --- | 'toggleterm' backend = nil < -TMUX & WEZTERM +TMUX, WEZTERM, AND ZELLIJ -The tmux and wezterm backends both have 2 built in pane directions, right of -the editor pane, and below the editor pane. +The tmux, wezterm, and zellij backends each have 2 built in pane directions, +right of the editor pane, and below the editor pane. TOGGLETERM @@ -168,6 +172,7 @@ The defaults options are as follows: --- @alias backend_used --- | 'tmux' --- | 'wezterm' + --- | 'zellij' --- | 'toggleterm' backend = nil @@ -206,10 +211,10 @@ TODO *command.nvim-toc-todo* - tmux - ToggleTerm - wezterm + - zellij - default nvim terminal - kitty - some other backend - - zellij - clean up types - types for all opts, use lua-ls enums - move to utils ? diff --git a/lua/command/command-types.lua b/lua/command/command-types.lua index 3538da2..d005cfb 100644 --- a/lua/command/command-types.lua +++ b/lua/command/command-types.lua @@ -2,12 +2,16 @@ --- | 'wezterm' --- | 'tmux' --- | 'toggleterm' +--- | 'zellij' --- | 'auto' -- pick automatically by examining environment vars --- @alias rule table --- @class direction --- @field name string +--- @field new string|nil +--- @field old string|nil +--- @field split string|nil --- @class backend --- @field run fun(string) diff --git a/lua/command/init.lua b/lua/command/init.lua index b57af78..2590cf6 100644 --- a/lua/command/init.lua +++ b/lua/command/init.lua @@ -11,7 +11,7 @@ M.CommandDirection = 1 M.ChangeDirection = function() if not M.backend.directions then - notify('Changing directions is not supported using backend: ' .. vim.g.command.use, 'error') + notify('Changing directions is not supported using backend: ' .. vim.g.command.backend, 'error') return end M.CommandDirection = (M.CommandDirection % #M.backend.directions + 1) diff --git a/lua/command/zellij.lua b/lua/command/zellij.lua new file mode 100644 index 0000000..55f6c35 --- /dev/null +++ b/lua/command/zellij.lua @@ -0,0 +1,233 @@ +local system = require('command.utils').system +local notify = require('command.utils').notify + +--- @type direction[] +local directions = { + { + name = 'pane on right side', + new = 'right', + split = 'right', + }, + { + name = 'pane below editor', + new = 'down', + split = 'down', + }, +} + +---@param p table +---@return string|nil +local function terminal_pane_id(p) + if p.is_plugin then + return nil + end + return 'terminal_' .. p.id +end + +---@param a table +---@param b table +---@return boolean +local function overlaps_vertical(a, b) + local ay2 = a.pane_y + a.pane_rows + local by2 = b.pane_y + b.pane_rows + return math.max(a.pane_y, b.pane_y) < math.min(ay2, by2) +end + +---@param a table +---@param b table +---@return boolean +local function overlaps_horizontal(a, b) + local ax2 = a.pane_x + a.pane_columns + local bx2 = b.pane_x + b.pane_columns + return math.max(a.pane_x, b.pane_x) < math.min(ax2, bx2) +end + +---@param focused table +---@param panes table[] +---@return table|nil +local function find_right_neighbor(focused, panes) + local right_edge = focused.pane_x + focused.pane_columns + local best + local best_dist + for _, p in ipairs(panes) do + if not p.is_plugin and p.id ~= focused.id then + if p.pane_x >= right_edge and overlaps_vertical(focused, p) then + local dist = p.pane_x - right_edge + if not best or dist < best_dist then + best = p + best_dist = dist + end + end + end + end + return best +end + +---@param focused table +---@param panes table[] +---@return table|nil +local function find_down_neighbor(focused, panes) + local bottom_edge = focused.pane_y + focused.pane_rows + local best + local best_dist + for _, p in ipairs(panes) do + if not p.is_plugin and p.id ~= focused.id then + if p.pane_y >= bottom_edge and overlaps_horizontal(focused, p) then + local dist = p.pane_y - bottom_edge + if not best or dist < best_dist then + best = p + best_dist = dist + end + end + end + end + return best +end + +---@param panes table[] +---@return table|nil +local function find_focused_terminal(panes) + for _, p in ipairs(panes) do + if p.is_focused and not p.is_plugin then + return p + end + end + local zid = vim.env.ZELLIJ_PANE_ID + if zid then + local num = zid:match 'terminal_(%d+)' or zid:match '^(%d+)$' + if num then + for _, p in ipairs(panes) do + if not p.is_plugin and tostring(p.id) == num then + return p + end + end + end + end + return nil +end + +---@return table[]|nil, string? +local function list_panes() + local out, err = system { 'zellij', 'action', 'list-panes', '--json', '--geometry' } + if err then + return nil, err + end + local ok, decoded = pcall(vim.json.decode, out) + if not ok or type(decoded) ~= 'table' then + return nil, 'failed to parse list-panes JSON' + end + return decoded, nil +end + +---@param panes table[] +---@return table +local function terminal_ids_set(panes) + local set = {} + for _, p in ipairs(panes) do + if not p.is_plugin then + set[p.id] = true + end + end + return set +end + +---@param dir string +---@return table|nil, string? +local function split_pane(dir) + local before, err = list_panes() + if err then + return nil, err + end + if not before then + return nil, 'list-panes returned no data' + end + local before_ids = terminal_ids_set(before) + local _, split_err = system { 'zellij', 'action', 'new-pane', '--direction', dir } + if split_err then + return nil, split_err + end + local after, err2 = list_panes() + if err2 then + return nil, err2 + end + if not after then + return nil, 'list-panes returned no data after split' + end + for _, p in ipairs(after) do + if not p.is_plugin and not before_ids[p.id] then + return p, nil + end + end + return nil, 'could not find new pane after split' +end + +---@param cmd string +---@param pane_id string +---@return string? +local function write_chars(cmd, pane_id) + local _, err = system { 'zellij', 'action', 'write-chars', cmd .. '\n', '--pane-id', pane_id } + return err +end + +---@param cmd string +local function zellij_run(cmd) + local panes, err = list_panes() + if err then + notify(err, 'error') + return + end + if not panes then + notify('list-panes returned no data', 'error') + return + end + local focused = find_focused_terminal(panes) + if not focused then + notify('could not find focused terminal pane in Zellij', 'error') + return + end + + local direction = directions[require('command').CommandDirection] + if not direction or not direction.new or not direction.split then + notify('invalid pane direction', 'error') + return + end + local neighbor + if direction.new == 'right' then + neighbor = find_right_neighbor(focused, panes) + elseif direction.new == 'down' then + neighbor = find_down_neighbor(focused, panes) + end + + local target = neighbor + if not target then + local new_pane, split_err = split_pane(direction.split) + if split_err then + notify(split_err, 'error') + return + end + if not new_pane then + notify('split did not create a new pane', 'error') + return + end + target = new_pane + end + + local pane_id = terminal_pane_id(target) + if not pane_id then + notify('target pane has no terminal id', 'error') + return + end + + local werr = write_chars(cmd, pane_id) + if werr then + notify(werr, 'error') + end +end + +--- @type backend +local M = { + run = zellij_run, + directions = directions, +} + +return M diff --git a/plugin/command.lua b/plugin/command.lua index 29d531b..95dcbe7 100644 --- a/plugin/command.lua +++ b/plugin/command.lua @@ -27,6 +27,8 @@ local opts = vim.tbl_deep_extend('force', default_opts, vim.g.command or {}) if not opts.backend then if vim.env.TMUX then opts.backend = 'tmux' + elseif vim.env.ZELLIJ or vim.env.ZELLIJ_SESSION_NAME then + opts.backend = 'zellij' elseif vim.env.TERM == 'wezterm' then opts.backend = 'wezterm' elseif pcall(require, 'toggleterm') then From ac91bc5b9dd8567e57a9d4f2fab36d7d7c0f32de Mon Sep 17 00:00:00 2001 From: cultab <51170833+cultab@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:18:22 +0000 Subject: [PATCH 3/4] docs(vimdoc):auto generate --- doc/command.nvim.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/command.nvim.txt b/doc/command.nvim.txt index 1481341..be9c2b4 100644 --- a/doc/command.nvim.txt +++ b/doc/command.nvim.txt @@ -106,7 +106,7 @@ default, heuristics are used to pick one of the supported backends. - If `$TMUX` is set, tmux is used. - Else if `$ZELLIJ` or `$ZELLIJ_SESSION_NAME` is set (Neovim is running inside - Zellij), zellij is used. + Zellij), zellij is used. - Else if `$TERM` is `wezterm`, wezterm is used. - Else if toggleterm’s module can be `require()`’ed, toggleterm is used. From 0194e9f3cfa9e5332ac59e6f4946b27663a9c54c Mon Sep 17 00:00:00 2001 From: cultab Date: Thu, 26 Mar 2026 01:39:16 +0200 Subject: [PATCH 4/4] feat: add zellij floating pane support Made-with: Cursor end of the Cursor experiment. --- README.md | 18 ++++--- doc/command.nvim.txt | 7 ++- lua/command/zellij.lua | 114 +++++++++++++++++++++++++++++++++-------- 3 files changed, 110 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d3f95a5..0d9d04a 100644 --- a/README.md +++ b/README.md @@ -79,13 +79,17 @@ vim.g.command = {--[[ options go here ]]} The backend is the multiplexer or terminal to use. It's controlled by the `backend` key in the options table. If the `backend` key is unset, as it is by default, heuristics are used to pick one of the supported backends. -* If `$TMUX` is set, tmux is used. +The backend is selected using the following table of fallbacks, checked in order from top to bottom. The first condition that matches determines which backend will be used: -* Else if `$ZELLIJ` or `$ZELLIJ_SESSION_NAME` is set (Neovim is running inside Zellij), zellij is used. +| Condition | Backend | +| ------------------------------------------------- | ---------- | +| `$TMUX` is set | tmux | +| `$ZELLIJ` or `$ZELLIJ_SESSION_NAME` is set | zellij | +| `$TERM` is `wezterm` | wezterm | +| toggleterm's module can be `require()`'ed | toggleterm | -* Else if `$TERM` is `wezterm`, wezterm is used. - -* Else if toggleterm's module can be `require()`'ed, toggleterm is used. +This order ensures that the most specific environment (e.g., tmux or zellij) is prioritized. +**If none of these heuristics succeed, you must explicitly set a backend using the `backend` key in your configuration. Otherwise, an error will be raised and no backend will be used.** ```lua --- @alias backend_used @@ -98,7 +102,9 @@ If the `backend` key is unset, as it is by default, heuristics are used to pick #### tmux, wezterm, and zellij -The tmux, wezterm, and zellij backends each have 2 built in pane directions, right of the editor pane, and below the editor pane. +The tmux and wezterm backends each have 2 built in pane directions, right of the editor pane, and below the editor pane. + +The zellij backend has those two directions plus a third that sends commands to a floating pane (creating one if none exists). #### ToggleTerm diff --git a/doc/command.nvim.txt b/doc/command.nvim.txt index be9c2b4..d5101a4 100644 --- a/doc/command.nvim.txt +++ b/doc/command.nvim.txt @@ -122,8 +122,11 @@ default, heuristics are used to pick one of the supported backends. TMUX, WEZTERM, AND ZELLIJ -The tmux, wezterm, and zellij backends each have 2 built in pane directions, -right of the editor pane, and below the editor pane. +The tmux and wezterm backends each have 2 built in pane directions, right of +the editor pane, and below the editor pane. + +The zellij backend has those two directions plus a third that sends commands to +a floating pane (creating one if none exists). TOGGLETERM diff --git a/lua/command/zellij.lua b/lua/command/zellij.lua index 55f6c35..1a643bb 100644 --- a/lua/command/zellij.lua +++ b/lua/command/zellij.lua @@ -13,6 +13,10 @@ local directions = { new = 'down', split = 'down', }, + { + name = 'floating pane', + new = 'floating', + }, } ---@param p table @@ -106,9 +110,26 @@ local function find_focused_terminal(panes) return nil end +--- Prefer focused floating terminal, else any floating terminal pane. +---@param panes table[] +---@return table|nil +local function find_floating_terminal(panes) + for _, p in ipairs(panes) do + if not p.is_plugin and p.is_floating and p.is_focused then + return p + end + end + for _, p in ipairs(panes) do + if not p.is_plugin and p.is_floating then + return p + end + end + return nil +end + ---@return table[]|nil, string? local function list_panes() - local out, err = system { 'zellij', 'action', 'list-panes', '--json', '--geometry' } + local out, err = system { 'zellij', 'action', 'list-panes', '--json', '--geometry', '--state' } if err then return nil, err end @@ -161,6 +182,35 @@ local function split_pane(dir) return nil, 'could not find new pane after split' end +---@return table|nil, string? +local function new_floating_pane() + local before, err = list_panes() + if err then + return nil, err + end + if not before then + return nil, 'list-panes returned no data' + end + local before_ids = terminal_ids_set(before) + local _, split_err = system { 'zellij', 'action', 'new-pane', '--floating' } + if split_err then + return nil, split_err + end + local after, err2 = list_panes() + if err2 then + return nil, err2 + end + if not after then + return nil, 'list-panes returned no data after new floating pane' + end + for _, p in ipairs(after) do + if not p.is_plugin and not before_ids[p.id] then + return p, nil + end + end + return nil, 'could not find new floating pane' +end + ---@param cmd string ---@param pane_id string ---@return string? @@ -180,36 +230,58 @@ local function zellij_run(cmd) notify('list-panes returned no data', 'error') return end - local focused = find_focused_terminal(panes) - if not focused then - notify('could not find focused terminal pane in Zellij', 'error') - return - end local direction = directions[require('command').CommandDirection] - if not direction or not direction.new or not direction.split then + if not direction or not direction.new then notify('invalid pane direction', 'error') return end - local neighbor - if direction.new == 'right' then - neighbor = find_right_neighbor(focused, panes) - elseif direction.new == 'down' then - neighbor = find_down_neighbor(focused, panes) - end - local target = neighbor - if not target then - local new_pane, split_err = split_pane(direction.split) - if split_err then - notify(split_err, 'error') + local target + + if direction.new == 'floating' then + target = find_floating_terminal(panes) + if not target then + local fpane, ferr = new_floating_pane() + if ferr then + notify(ferr, 'error') + return + end + if not fpane then + notify('could not create floating pane', 'error') + return + end + target = fpane + end + else + if not direction.split then + notify('invalid pane direction', 'error') return end - if not new_pane then - notify('split did not create a new pane', 'error') + local focused = find_focused_terminal(panes) + if not focused then + notify('could not find focused terminal pane in Zellij', 'error') return end - target = new_pane + local neighbor + if direction.new == 'right' then + neighbor = find_right_neighbor(focused, panes) + elseif direction.new == 'down' then + neighbor = find_down_neighbor(focused, panes) + end + target = neighbor + if not target then + local new_pane, split_err = split_pane(direction.split) + if split_err then + notify(split_err, 'error') + return + end + if not new_pane then + notify('split did not create a new pane', 'error') + return + end + target = new_pane + end end local pane_id = terminal_pane_id(target)