diff --git a/README.md b/README.md index 2cd9c9c..7725b44 100644 --- a/README.md +++ b/README.md @@ -304,10 +304,19 @@ local defaults = { ---@class sidekick.cli.Mux ---@field backend? "tmux"|"zellij" Multiplexer backend to persist CLI sessions mux = { - backend = "zellij", + backend = "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 + -- 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" + split = { + vertical = true, -- vertical or horizontal split + size = 0.5, -- size of the split (0-1 for percentage) + }, }, - ---@type table + ---@type table tools = { aider = { cmd = { "aider" }, url = "https://github.com/Aider-AI/aider" }, amazon_q = { cmd = { "q" }, url = "https://github.com/aws/amazon-q-developer-cli" }, diff --git a/lua/sidekick/cli/init.lua b/lua/sidekick/cli/init.lua index 6fe1722..a3ff4dc 100644 --- a/lua/sidekick/cli/init.lua +++ b/lua/sidekick/cli/init.lua @@ -1,7 +1,5 @@ -local Config = require("sidekick.config") local Context = require("sidekick.cli.context") -local Session = require("sidekick.cli.session") -local Terminal = require("sidekick.cli.terminal") +local State = require("sidekick.cli.state") local Util = require("sidekick.util") local M = {} @@ -13,45 +11,22 @@ local M = {} ---@field msg? string ---@field prompt? string ----@class sidekick.cli.Tool.spec +---@class sidekick.cli.Config ---@field cmd string[] Command to run the CLI tool ----@field env? table Environment variables to set when running the command +---@field env? table Environment variables to set when running the command ---@field url? string Web URL to open when the tool is not installed ---@field keys? table - ----@class sidekick.cli.Tool: sidekick.cli.Tool.spec ----@field name string ----@field installed? boolean ----@field running? boolean ----@field mux? boolean ----@field session? sidekick.cli.Session - ----@class sidekick.cli.Filter ----@field name? string ----@field session? string ----@field installed? boolean ----@field running? boolean ----@field cwd? boolean - ----@class sidekick.cli.With ----@field filter? sidekick.cli.Filter ----@field create? boolean ----@field all? boolean +---@field is_proc? (fun(self:sidekick.cli.Tool, proc:sidekick.cli.Proc):boolean)|string Regex or function to identity a running process ---@class sidekick.cli.Show ---@field name? string ---@field focus? boolean ---@field filter? sidekick.cli.Filter ----@field on_show? fun(terminal: sidekick.cli.Terminal) ---@class sidekick.cli.Hide ---@field name? string ---@field all? boolean ----@class sidekick.cli.Select: sidekick.cli.With ----@field on_select? fun(t:sidekick.cli.Tool) ----@field auto? boolean Automatically select if only one tool matches the filter - ---@class sidekick.cli.Send: sidekick.cli.Show,sidekick.cli.Message ---@field submit? boolean ---@field render? boolean @@ -71,241 +46,49 @@ local function show_opts(opts) return opts end ----@param opts? sidekick.cli.Select -function M.select(opts) +---@param opts? sidekick.cli.Prompt|{cb:nil} +---@overload fun(cb:fun(msg?:string)) +function M.prompt(opts) opts = opts or {} - local tools = M.get_tools(opts.filter) - - ---@param tool? sidekick.cli.Tool - local on_select = function(tool) - if tool then - if not tool.installed then - if tool.url then - local ok, err = vim.ui.open(tool.url) - if ok then - Util.info(("Opening %s in your browser..."):format(tool.url)) - else - Util.error(("Failed to open %s: %s"):format(tool.url, err)) - end - else - Util.error(("Tool `%s` is not installed"):format(tool.name)) - end - return - end - if opts.on_select then - return opts.on_select(tool) - end - M.show({ - filter = { name = tool.name, session = tool.session and tool.session.id or nil }, - focus = opts.focus, - }) + opts = type(opts) == "function" and { cb = opts } or opts --[[@as sidekick.cli.Prompt]] + opts.cb = opts.cb or function(msg) + if msg then + M.send({ msg = msg, render = false }) end end - - if #tools == 0 then - Util.warn("No tools match the given filter") - return - elseif #tools == 1 and opts.auto then - on_select(tools[1]) - return - end - - ---@param tool sidekick.cli.Tool|snacks.picker.Item - ---@param picker? snacks.Picker - local format = function(tool, picker) - local sw = vim.api.nvim_strwidth - local ret = {} ---@type snacks.picker.Highlight[] - if picker then - local count = picker:count() - local idx = tostring(tool.idx) - idx = (" "):rep(#tostring(count) - #idx) .. idx - ret[#ret + 1] = { idx .. ".", "SnacksPickerIdx" } - ret[#ret + 1] = { " " } - end - ret[#ret + 1] = { tool.installed and "✅" or "❌" } - ret[#ret + 1] = { " " } - ret[#ret + 1] = { tool.name } - local len = sw(tool.name) + 2 - if tool.mux then - local backend = ("[%s]"):format(Config.cli.mux.backend) - ret[#ret + 1] = { string.rep(" ", 12 - len) } - ret[#ret + 1] = { backend, "Special" } - len = 12 + sw(backend) - elseif tool.running then - ret[#ret + 1] = { string.rep(" ", 12 - len) } - ret[#ret + 1] = { "[running]", "Special" } - len = 12 + sw("[running]") - end - if tool.session then - ret[#ret + 1] = { string.rep(" ", 22 - len) } - if picker then - local item = vim.deepcopy(tool) --[[@as snacks.picker.Item]] - item.file = tool.session.cwd - item.dir = true - vim.list_extend(ret, require("snacks").picker.format.filename(item, picker)) - else - ret[#ret + 1] = { vim.fn.fnamemodify(tool.session.cwd, ":p:~"), "Directory" } - end - end - return ret - end - - ---@type snacks.picker.ui_select.Opts - local select_opts = { - prompt = "Select CLI tool:", - picker = { - format = format, - }, - kind = "snacks", - ---@param tool sidekick.cli.Tool - format_item = function(tool, is_snacks) - local parts = format(tool) - return is_snacks and parts or table.concat(vim.tbl_map(function(p) - return p[1] - end, parts)) - end, - } - - vim.ui.select(tools, select_opts, on_select) + require("sidekick.cli.ui.prompt").select(opts) end ----@param t sidekick.cli.Terminal|sidekick.cli.Tool ----@param filter? sidekick.cli.Filter -function M.is(t, filter) - filter = filter or {} - t = getmetatable(t) == Terminal and t.tool or t - ---@cast t sidekick.cli.Tool - local terminal = t.session and Terminal.get(t.session.id) - return (filter.name == nil or filter.name == t.name) - and (filter.installed == nil or filter.installed == t.installed) - and (filter.session == nil or (t.session and t.session.id == filter.session)) - and (filter.running == nil or filter.running == (terminal and terminal:is_running())) - and (filter.cwd == nil or (t.session and t.session.cwd == Session.cwd())) -end - ----@param filter? sidekick.cli.Filter ----@return sidekick.cli.Tool[] -function M.get_tools(filter) - local Mux = require("sidekick.cli.mux") - local all = {} ---@type sidekick.cli.Tool[] - - local sessions = Mux.sessions() - for id, session in pairs(Terminal.sessions()) do - sessions[id] = session - end - - for _, session in pairs(sessions) do - local t = vim.deepcopy(Config.cli.tools[session.tool]) --[[@as sidekick.cli.Tool?]] - if t then - t.name = session.tool - t.session = session - t.installed = true - t.running = session.mux == nil - t.mux = session.mux ~= nil - all[#all + 1] = t - end - end - - for name, tool in pairs(vim.deepcopy(Config.cli.tools)) do - ---@cast tool sidekick.cli.Tool - tool.name = name - local id = Session.new(tool).id - if not sessions[id] then - tool.installed = vim.fn.executable(tool.cmd[1]) == 1 - tool.running = false - tool.mux = false - all[#all + 1] = tool - end - end - - local cwd = Session.cwd() - - ---@type sidekick.cli.Tool[] - ---@param t sidekick.cli.Tool - local ret = vim.tbl_filter(function(t) - return M.is(t, filter) - end, all) - table.sort(ret, function(a, b) - if a.installed ~= b.installed then - return a.installed - end - if a.running ~= b.running then - return a.running - end - -- sessions in cwd, or tools without a session - local a_cwd = (not a.session or a.session.cwd == cwd or false) - local b_cwd = (not b.session or b.session.cwd == cwd or false) - if a_cwd ~= b_cwd then - return a_cwd - end - if a.mux ~= b.mux then - return a.mux - end - return a.name < b.name - end) - return ret -end - ----@param filter? sidekick.cli.Filter -function M.get_terminals(filter) - ---@param t sidekick.cli.Terminal - local ret = vim.tbl_filter(function(t) - return M.is(t, filter) - end, Terminal.terminals) --[[@as sidekick.cli.Terminal[] ]] - table.sort(ret, function(a, b) - return a.atime > b.atime - end) - return ret -end - ----@param cb fun(terminal: sidekick.cli.Terminal) ----@param opts? sidekick.cli.With -function M.with(cb, opts) +---@param opts? sidekick.cli.Select|{cb:nil}|{focus?:boolean} +---@overload fun(cb:fun(state?:sidekick.cli.State)) +function M.select(opts) opts = opts or {} - cb = vim.schedule_wrap(cb) - local terminals = M.get_terminals(opts.filter) - terminals = opts.all and terminals or { terminals[1] } - if #terminals == 0 and opts.create then - M.select({ - auto = true, - filter = opts.filter, - on_select = function(tool) - if vim.fn.executable(tool.cmd[1]) == 0 then - Util.error(("`%s` is not installed"):format(tool.cmd[1])) - return - end - cb(Terminal.new(tool)) - end, - }) - else - vim.tbl_map(cb, terminals) - end + opts = type(opts) == "function" and { cb = opts } or opts --[[@as sidekick.cli.Select]] + opts.cb = opts.cb + or function(state) + if state then + State.attach(state, { show = true, focus = opts.focus }) + end + end + require("sidekick.cli.ui.select").select(opts) end ---@param opts? sidekick.cli.Show ---@overload fun(name: string) function M.show(opts) opts = show_opts(opts) - M.with(function(t) - t:show() - if t:is_open() then - if opts.focus ~= false then - t:focus() - end - if opts.on_show then - vim.schedule(function() - opts.on_show(t) - end) - end - end - end, { filter = opts.filter, create = true }) + M.with(function(t) end, { filter = opts.filter, create = true, show = true, focus = opts.focus }) end ---@param opts? sidekick.cli.Show ---@overload fun(name: string) function M.toggle(opts) opts = show_opts(opts) - M.with(function(t) + State.with(function(state) + local t = state.terminal + if not t then + return + end t:toggle() if t:is_open() and opts.focus ~= false then t:focus() @@ -318,13 +101,17 @@ end ---@overload fun(name: string) function M.focus(opts) opts = show_opts(opts) - M.with(function(t) + State.with(function(state) + local t = state.terminal + if not t then + return + end if t:is_focused() then t:blur() else t:focus() end - end, { filter = opts.filter, create = true }) + end, { filter = opts.filter, create = true, show = true, focus = opts.focus }) end ---@param opts? sidekick.cli.Hide @@ -333,7 +120,7 @@ function M.hide(opts) opts = type(opts) == "string" and { name = opts } or opts or {} M.with(function(t) t:hide() - end, { filter = { name = opts.name, running = true }, all = opts.all }) + end, { filter = { name = opts.name, running = true }, all = opts.all }, { filter = { terminal = true } }) end ---@param opts? sidekick.cli.Hide @@ -342,7 +129,7 @@ function M.close(opts) opts = type(opts) == "string" and { name = opts } or opts or {} M.with(function(t) t:close() - end, { filter = { name = opts.name, running = true }, all = opts.all }) + end, { filter = { name = opts.name, running = true }, all = opts.all }, { filter = { terminal = true } }) end ---@param opts? sidekick.cli.Message|string @@ -366,113 +153,18 @@ function M.send(opts) return end - opts.on_show = function(terminal) + State.with(function(state) Util.exit_visual_mode() vim.schedule(function() - terminal:send(msg .. "\n") + if opts.focus ~= false and state.terminal then + state.terminal:focus() + end + state.session:send(msg .. "\n") if opts.submit then - terminal:submit() + state.session:submit() end end) - end - - M.show(opts) -end - ----@param cb? fun(msg?: string) -function M.prompt(cb) - local prompts = vim.tbl_keys(Config.cli.prompts) ---@type string[] - table.sort(prompts) - local context = Context.get() - local test = Context.get() - test.get = function(_, name) - if name == "nl" then - return { { { "\\n", "@string.escape" } } } - end - return { { { ("{%s}"):format(name), "Special" } } } - end - - local items = {} ---@type snacks.picker.finder.Item[] - for _, name in ipairs(prompts) do - local prompt = Config.cli.prompts[name] or {} - prompt = type(prompt) == "string" and { msg = prompt } or prompt - ---@cast prompt sidekick.Prompt - prompt.msg = prompt.msg or "" - local text, rendered = context:render({ prompt = name }) - if rendered and #rendered > 0 then - local extmarks = {} ---@type snacks.picker.Extmark[] - for l, line in ipairs(rendered) do - local col = 0 - for _, hl in ipairs(line) do - if hl[1] then - if hl[2] then - extmarks[#extmarks + 1] = { - row = l, - col = col, - end_col = col + #hl[1], - hl_group = hl[2], - } - end - col = col + #hl[1] - end - end - end - ---@class sidekick.select_prompt.Item: snacks.picker.finder.Item - items[#items + 1] = { - text = name, - data = text, - name = name, - prompt = prompt, - preview = { - text = text, - extmarks = extmarks, - }, - } - end - end - - ---@type snacks.picker.ui_select.Opts - local opts = { - prompt = "Select a prompt", - ---@param item sidekick.select_prompt.Item - format_item = function(item, is_snacks) - if is_snacks then - local ret = {} ---@type snacks.picker.Highlight[] - ret[#ret + 1] = { item.name, "Title" } - ret[#ret + 1] = { string.rep(" ", 18 - #item.name) } - local _, prompt = test:render({ msg = item.prompt.msg:gsub("\n", "{nl}"), this = false }) - vim.list_extend(ret, prompt and prompt[1] or {}) - return ret - end - return ("[%s] %s"):format(item.name, string.rep(" ", 18 - #item.name) .. item.prompt.msg) - end, - picker = { - preview = "preview", - layout = { - preset = "vscode", - min_height = 0.6, - preview = true, - }, - win = { - input = { - keys = { - [""] = { "yank", mode = { "n", "i" } }, - ["y"] = { "yank" }, - }, - }, - }, - }, - } - - ---@param choice? sidekick.select_prompt.Item - vim.ui.select(items, opts, function(choice) - if cb then - return cb(choice and choice.preview.text or nil) - end - if choice then - M.send({ msg = choice.preview.text, render = false }) - end - end) + end, { filter = opts.filter, create = true, show = true }) end ---@deprecated use `require("sidekick.cli").prompt()` diff --git a/lua/sidekick/cli/mux/init.lua b/lua/sidekick/cli/mux/init.lua deleted file mode 100644 index b667657..0000000 --- a/lua/sidekick/cli/mux/init.lua +++ /dev/null @@ -1,72 +0,0 @@ -local Config = require("sidekick.config") -local Session = require("sidekick.cli.session") -local Util = require("sidekick.util") - ----@class sidekick.cli.mux.Opts ----@field cwd? string - ----@class sidekick.cli.Muxer ----@field tool sidekick.cli.Tool ----@field session sidekick.cli.Session ----@field backend "tmux"|"zellij" ----@field cwd string -local M = {} -M.__index = M - ----@param tool sidekick.cli.Tool ----@param session sidekick.cli.Session -function M.new(tool, session) - local super = M.get() - if not super then - return - end - ---@type sidekick.cli.Muxer - local self = setmetatable({}, { __index = super }) - self.tool = tool - session.mux = self.backend - self.session = session - return self -end - ----@return sidekick.cli.Tool.spec? -function M:cmd() - error("Muxer:cmd() not implemented") -end - ----@return string[]? -function M._sessions() - error("Muxer:cmd() not implemented") -end - ----@return table -function M.sessions() - local mux = M.get() - if not mux then - return {} - end - local sessions = mux._sessions() or {} - local ret = {} ---@type table - for _, id in ipairs(sessions) do - local s = Session.get(id) - if s then - s.mux = mux.backend - ret[id] = s - end - end - return ret -end - -function M.get() - if not Config.cli.mux.enabled then - return - end - ---@type boolean, sidekick.cli.Muxer - local ok, ret = pcall(require, "sidekick.cli.mux." .. Config.cli.mux.backend) - if not ok then - Util.error("Invalid **mux** backend `" .. Config.cli.mux.backend .. "`") - return - end - return ret -end - -return M diff --git a/lua/sidekick/cli/mux/tmux.lua b/lua/sidekick/cli/mux/tmux.lua deleted file mode 100644 index 6a4f535..0000000 --- a/lua/sidekick/cli/mux/tmux.lua +++ /dev/null @@ -1,33 +0,0 @@ -local Util = require("sidekick.util") - ----@class sidekick.cli.muxer.Tmux: sidekick.cli.Muxer -local M = {} -M.backend = "tmux" -setmetatable(M, require("sidekick.cli.mux")) - ----@return sidekick.cli.Tool.spec? -function M:cmd() - if vim.fn.executable("tmux") ~= 1 then - Util.error("tmux executable not found on $PATH") - return - end - - local cmd = { "tmux", "new", "-A", "-s", self.session.id } - for key, value in pairs(self.tool.env or {}) do - if value == false then - vim.list_extend(cmd, { "-u", key }) -- unset - else - vim.list_extend(cmd, { "-e", ("%s=%s"):format(key, tostring(value)) }) - end - end - vim.list_extend(cmd, self.tool.cmd) - vim.list_extend(cmd, { ";", "set-option", "status", "off" }) - vim.list_extend(cmd, { ";", "set-option", "detach-on-destroy", "on" }) - return { cmd = cmd } -end - -function M._sessions() - return Util.exec({ "tmux", "list-sessions", "-F", "#{session_name}" }) -end - -return M diff --git a/lua/sidekick/cli/mux/zellij.lua b/lua/sidekick/cli/mux/zellij.lua deleted file mode 100644 index cd3846a..0000000 --- a/lua/sidekick/cli/mux/zellij.lua +++ /dev/null @@ -1,61 +0,0 @@ -local Config = require("sidekick.config") -local Util = require("sidekick.util") - ----@class sidekick.cli.muxer.Zellij: sidekick.cli.Muxer -local M = {} -M.backend = "zellij" -setmetatable(M, require("sidekick.cli.mux")) - -M.tpl = [[ -layout { - pane command="{cmd}" { - borderless true - focus true - name "{name}" - close_on_exit true - {args} - } -} -session_serialization false -]] - ----@return sidekick.cli.Tool.spec? -function M:cmd() - if vim.fn.executable("zellij") ~= 1 then - Util.error("zellij executable not found on $PATH") - return - end - - local layout = M.tpl - layout = layout:gsub("{cmd}", self.tool.cmd[1]) - layout = layout:gsub("{name}", self.tool.name) - if #self.tool.cmd == 1 then - layout = layout:gsub("{args}", "") - else - local args = vim.list_slice(self.tool.cmd, 2) - layout = layout:gsub("{args}", "args " .. table.concat( - vim.tbl_map(function(a) - return ("%q"):format(a) - end, args), - " " - )) --[[@as string]] - end - - local layout_file = Config.state("zellij-layout-" .. self.session.id .. ".kdl") - vim.fn.writefile(vim.split(layout, "\n"), layout_file) - - return { - cmd = { "zellij", "--layout", layout_file, "attach", "--create", self.session.id }, - env = { - ZELLIJ = false, - ZELLIJ_SESSION_NAME = false, - ZELLIJ_PANE_ID = false, - }, - } -end - -function M._sessions() - return Util.exec({ "zellij", "list-sessions", "-ns" }) -end - -return M diff --git a/lua/sidekick/cli/procs.lua b/lua/sidekick/cli/procs.lua new file mode 100644 index 0000000..ce28a6b --- /dev/null +++ b/lua/sidekick/cli/procs.lua @@ -0,0 +1,172 @@ +local Util = require("sidekick.util") + +local have_proc = vim.uv.fs_stat("/proc/self") ~= nil + +---@param pid number +local function get_env(pid) + local env = {} ---@type table + + if have_proc then + -- Linux: use /proc filesystem + local e = io.open("/proc/" .. pid .. "/environ", "r") + if e then + local env_data = e:read("*all") + e:close() + local env_lines = vim.split(env_data, "\0") + for _, env_line in ipairs(env_lines) do + local k, v = env_line:match("^(.-)=(.*)$") + if k and v then + env[k] = v + end + end + end + end + + -- try ps as a fallback (macOS and others) + local lines = Util.exec({ "ps", "eww", "-p", tostring(pid) }) + if lines and #lines > 0 then + -- ps eww output format: PID command ENV1=val1 ENV2=val2 ... + local line = lines[1] + -- Skip the PID and command, extract environment variables + for env_var in line:gmatch("(%w+=[^%s]+)") do + local k, v = env_var:match("^(.-)=(.*)$") + if k and v then + env[k] = v + end + end + end + return env +end + +---@param pid number +local function get_cwd(pid) + if have_proc then + -- Linux: use /proc filesystem + local ret = vim.uv.fs_readlink("/proc/" .. pid .. "/cwd") + return ret and vim.fs.normalize(ret) or false + end + + -- try lsof as a fallback (macOS and others) + local lines = Util.exec({ "lsof", "-a", "-d", "cwd", "-p", tostring(pid), "-Fn" }) + for _, line in ipairs(lines or {}) do + -- lsof -Fn output format: n/path/to/cwd + local path = line:match("^n(.+)$") + if path then + return vim.fs.normalize(path) + end + end + return false +end + +local proc_fields = { env = get_env, cwd = get_cwd } + +---@class sidekick.cli.Proc +---@field pid number +---@field ppid number +---@field cmd string +---@field env table +---@field cwd? string + +---@class sidekick.cli.Procs +---@field _procs table +---@field _children table +local M = {} +M.__index = M + +function M.new() + local self = setmetatable({}, M) + self._procs = {} + self._children = {} + self:update() + return self +end + +function M:update() + self._procs = {} + self._children = {} + if vim.fn.has("win32") == 1 then + return + end + + local lines = Util.exec({ "ps", "-u", vim.env.USER or "", "-ww", "-o", "pid,ppid,args" }) + lines = vim.list_slice(lines or {}, 2) -- skip header + + for _, line in ipairs(lines or {}) do + local pid, ppid, cmd = line:match("^%s*(%d+)%s+(%d+)%s+(.*)$") + if pid and ppid and cmd then + pid = assert(tonumber(pid), "invalid pid: " .. pid) --[[@as number]] + ppid = assert(tonumber(ppid), "invalid ppid: " .. ppid) --[[@as number]] + self._procs[pid] = setmetatable({ pid = pid, ppid = ppid, cmd = cmd }, { + __index = function(t, k) + local f = proc_fields[k] + if f then + local v = f(t.pid) + rawset(t, k, v) + return v + end + end, + }) + self._children[ppid] = self._children[ppid] or {} + table.insert(self._children[ppid], pid) + end + end +end + +---@param pid number +---@return sidekick.cli.Proc? +function M:get(pid) + return self._procs[pid] +end + +---@param pid number +function M:parent(pid) + local proc = self:get(pid) + return proc and self:get(proc.ppid) or nil +end + +function M:list() + return vim.tbl_values(self._procs) +end + +---@param pid number +function M:children(pid) + local children = self._children[pid] or {} + local ret = {} ---@type sidekick.cli.Proc[] + for _, cpid in ipairs(children) do + ret[#ret + 1] = self:get(cpid) + end + return ret +end + +---@param pid number +---@param cb? fun(proc: sidekick.cli.Proc):(true|nil) +function M:walk(pid, cb) + local todo = { pid } + local ret = {} ---@type sidekick.cli.Proc[] + while #todo > 0 do + local current = table.remove(todo, 1) + local proc = self:get(current) + if proc then + if cb and cb(proc) then + break + end + ret[#ret + 1] = proc + end + vim.list_extend(todo, self._children[current] or {}) + end + return ret +end + +---@param filter string|fun(proc: sidekick.cli.Proc):boolean +function M:find(filter) + if type(filter) == "string" then + local pattern = filter --[[@as string]] + ---@param proc sidekick.cli.Proc + filter = function(proc) + return proc.cmd:find(pattern) ~= nil + end + end + return vim.tbl_filter(filter, self._procs) +end + +return M.new() diff --git a/lua/sidekick/cli/session.lua b/lua/sidekick/cli/session.lua deleted file mode 100644 index 26966ce..0000000 --- a/lua/sidekick/cli/session.lua +++ /dev/null @@ -1,54 +0,0 @@ -local Config = require("sidekick.config") -local Util = require("sidekick.util") - -local M = {} - ----@class sidekick.cli.Session ----@field id string ----@field cwd string ----@field tool string ----@field mux? "tmux"|"zellij" - ----@param session sidekick.cli.Session -function M.save(session) - local path = Config.state(session.id .. ".json") - local data = vim.fn.json_encode(session) - vim.fn.writefile(vim.split(data, "\n"), path) -end - ----@param id string ----@return sidekick.cli.Session? -function M.get(id) - local path = Config.state(id .. ".json") - if vim.fn.filereadable(path) == 1 then - local data = vim.fn.readfile(path) - local ok, ret = pcall(vim.fn.json_decode, table.concat(data, "\n")) - if ok then - ---@cast ret sidekick.cli.Session - ---@diagnostic disable-next-line: undefined-field - ret.tool = type(ret.tool) == "table" and ret.tool.name or ret.tool - return ret - end - Util.error("Failed to decode session data: " .. tostring(ret)) - end -end - ----@param tool sidekick.cli.Tool ----@param opts? {cwd?:string} -function M.new(tool, opts) - local cwd = M.cwd(opts) - ---@type sidekick.cli.Session - local ret = { - id = ("%s %s"):format(tool.name, vim.fn.sha256(cwd):sub(1, 16 - #tool.name)), - cwd = cwd, - tool = tool.name, - } - return ret -end - ----@param opts? {cwd?:string} -function M.cwd(opts) - return vim.fs.normalize(vim.fn.fnamemodify(opts and opts.cwd or vim.fn.getcwd(0), ":p")) -end - -return M diff --git a/lua/sidekick/cli/session/init.lua b/lua/sidekick/cli/session/init.lua new file mode 100644 index 0000000..543d0dd --- /dev/null +++ b/lua/sidekick/cli/session/init.lua @@ -0,0 +1,149 @@ +local Config = require("sidekick.config") +local Util = require("sidekick.util") + +local M = {} + +M.backends = {} ---@type table +M.did_setup = false +M.attached = {} ---@type table + +---@class sidekick.cli.session.State +---@field id string unique id of the running tool (typically pid of tool) +---@field cwd string +---@field tool sidekick.cli.Tool|string +---@field backend? string +---@field started? boolean +---@field attached? boolean +---@field mux_session? string +---@field mux_backend? string + +---@alias sidekick.cli.session.Opts sidekick.cli.session.State|{cwd?:string,id?:string} + +---@class sidekick.cli.Session: sidekick.cli.session.State +---@field sid string unique id based on tool and cwd +---@field tool sidekick.cli.Tool +---@field backend string +local B = {} +B.__index = B + +---@param text string +function B:send(text) + error("Backend:send() not implemented") +end + +function B:init() end + +function B:submit() + error("Backend:submit() not implemented") +end + +---@return sidekick.cli.terminal.Cmd? +function B:attach() + error("Backend:attach() not implemented") +end + +---@return sidekick.cli.session.State[] +function B.sessions() + error("Backend:sessions() not implemented") +end + +---@param state sidekick.cli.session.Opts +function M.new(state) + local tool = state.tool + tool = type(tool) == "string" and Config.get_tool(tool) or tool --[[@as sidekick.cli.Tool]] + + local backend = state.backend or (Config.cli.mux.enabled and Config.cli.mux.backend or "terminal") + local super = assert(M.backends[backend], "unknown backend: " .. backend) + local meta = getmetatable(state) + local self = setmetatable(state, super) --[[@as sidekick.cli.Session]] + self.tool = tool + self.cwd = M.cwd(state) + -- self.cmd = state.cmd or { cmd = tool.cmd, env = tool.env } + self.backend = backend + self.sid = M.sid({ tool = tool.name, cwd = self.cwd }) + self.id = self.id or self.sid + self.attached = self.attached or M.attached[self.id] or false + if meta ~= super and self.init then + self:init() + end + return self +end + +---@param opts? {cwd?:string} +function M.cwd(opts) + return vim.fs.normalize(vim.fn.fnamemodify(opts and opts.cwd or vim.fn.getcwd(0), ":p")) +end + +---@param opts {tool:string, cwd?:string} +function M.sid(opts) + local tool = assert(opts and opts.tool, "missing tool") + local cwd = M.cwd(opts) + return ("%s %s"):format(tool, vim.fn.sha256(cwd):sub(1, 16 - #tool)) +end + +---@param name string +---@param backend sidekick.cli.Session +function M.register(name, backend) + setmetatable(backend, B) + backend.backend = name + M.backends[name] = backend +end + +function M.setup() + if M.did_setup then + return + end + M.did_setup = true + local session_backends = { tmux = "sidekick.cli.session.tmux", zellij = "sidekick.cli.session.zellij" } + for name, mod in pairs(session_backends) do + if vim.fn.executable(name) == 1 then + M.register(name, require(mod)) + end + end + M.register("terminal", require("sidekick.cli.terminal")) +end + +function M.sessions() + M.setup() + require("sidekick.cli.procs"):update() + local ret = {} ---@type sidekick.cli.Session[] + local ids = {} ---@type table + for name, backend in pairs(M.backends) do + for _, s in pairs(backend:sessions()) do + s.backend = name + s.started = true + ret[#ret + 1] = M.new(s) + ids[s.id] = true + end + end + for id in pairs(M.attached) do + if not ids[id] then + M.attached[id] = nil + end + end + return ret +end + +---@param session sidekick.cli.Session +function M.attach(session) + if M.attached[session.id] then + return session + end + local cmd = session:attach() + M.attached[session.id] = true + session.attached = true + if cmd then + return M.new({ + tool = session.tool:clone({ cmd = cmd.cmd, env = cmd.env }), + cwd = session.cwd, + id = session.sid, + backend = "terminal", + mux_backend = session.backend, + mux_session = session.mux_session, + attached = true, + }) + end + return session +end + +return M diff --git a/lua/sidekick/cli/session/tmux.lua b/lua/sidekick/cli/session/tmux.lua new file mode 100644 index 0000000..7f2d679 --- /dev/null +++ b/lua/sidekick/cli/session/tmux.lua @@ -0,0 +1,122 @@ +local Config = require("sidekick.config") +local Util = require("sidekick.util") + +---@class sidekick.cli.muxer.Tmux: sidekick.cli.Session +---@field tmux_pane_id string +local M = {} +M.__index = M + +---@return sidekick.cli.terminal.Cmd? +function M:attach() + if self.started then + if self.sid == self.mux_session then + return { cmd = { "tmux", "attach-session", "-t", self.sid } } + end + return -- nothing to do + end + + if Config.cli.mux.create == "terminal" or vim.env.TMUX == nil then + local cmd = { "tmux", "new", "-A", "-s", self.id } + vim.list_extend(cmd, { "-c", self.cwd }) + self:add_cmd(cmd) + vim.list_extend(cmd, { ";", "set-option", "status", "off" }) + vim.list_extend(cmd, { ";", "set-option", "detach-on-destroy", "on" }) + return { cmd = cmd } + elseif Config.cli.mux.create == "window" then + local cmd = { "tmux", "new-window", "-dP", "-c", self.cwd, "-F", "#{pane_pid}" } + self:add_cmd(cmd) + local lines = Util.exec(cmd) + if lines and lines[1] then + self.id = "tmux " .. lines[1] + end + elseif Config.cli.mux.create == "split" then + local cmd = { "tmux", "split-window", "-dP", "-c", self.cwd, "-F", "#{pane_pid}" } + cmd[#cmd + 1] = Config.cli.mux.split.vertical and "-h" or "-v" + local size = Config.cli.mux.split.size + vim.list_extend(cmd, { "-l", tostring(size <= 1 and ((size * 100) .. "%") or size) }) + self:add_cmd(cmd) + local lines = Util.exec(cmd) + if lines and lines[1] then + self.id = "tmux " .. lines[1] + end + end +end + +---@param ret string[] +function M:add_cmd(ret) + for key, value in pairs(self.tool.env or {}) do + if value == false then + vim.list_extend(ret, { "-u", key }) -- unset + else + vim.list_extend(ret, { "-e", ("%s=%s"):format(key, tostring(value)) }) + end + end + vim.list_extend(ret, self.tool.cmd) +end + +function M.panes() + -- List all panes in current session with their command and cwd + ---@type string[]? + local lines = Util.exec({ + "tmux", + "list-panes", + "-a", + "-F", + "#{pane_id}:#{pane_pid}:#{session_name}:#{?pane_current_path,#{pane_current_path},#{pane_start_path}}", + }, { notify = false }) + + local panes = {} ---@type sidekick.tmux.Pane[] + for _, line in ipairs(lines or {}) do + local id, pid, session_name, cwd = line:match("^(%%%d+):(%d+):(.-):(.*)$") + if id and pid and session_name and cwd then + local p = assert(tonumber(pid), "invalid tmux pane_pid: " .. pid) + ---@class sidekick.tmux.Pane + panes[#panes + 1] = { + id = id, + pid = p, + session_name = session_name, + cwd = cwd, + } + end + end + return panes +end + +function M.sessions() + local panes = M.panes() + local ret = {} ---@type sidekick.cli.session.State[] + local tools = Config.tools() + + local procs = require("sidekick.cli.procs") + for _, pane in ipairs(panes) do + procs:walk(pane.pid, function(proc) + for _, tool in pairs(tools) do + if tool:is_proc(proc) then + ret[#ret + 1] = { + id = ("tmux %s"):format(pane.pid), + cwd = proc.cwd or pane.cwd, + tool = tool, + tmux_pane_id = pane.id, + mux_session = pane.session_name, + } + return true + end + end + end) + end + + return ret +end + +---Send text to a tmux pane +function M:send(text) + Util.exec({ "tmux", "set-buffer", "-b", "sidekick", text }) + Util.exec({ "tmux", "paste-buffer", "-b", "sidekick", "-d", "-t", self.tmux_pane_id }) +end + +---Send text to a tmux pane +function M:submit() + Util.exec({ "tmux", "send-keys", "-t", self.tmux_pane_id, "Enter" }) +end + +return M diff --git a/lua/sidekick/cli/session/zellij.lua b/lua/sidekick/cli/session/zellij.lua new file mode 100644 index 0000000..ac13d32 --- /dev/null +++ b/lua/sidekick/cli/session/zellij.lua @@ -0,0 +1,116 @@ +local Config = require("sidekick.config") +local Util = require("sidekick.util") + +---@class sidekick.cli.muxer.Zellij: sidekick.cli.Session +---@field zellij_pane_id string +---@field zellij string +local M = {} +M.__index = M + +M.tpl = [[ +layout { + pane command="{cmd}" { + borderless true + focus true + name "{name}" + close_on_exit true + {args} + } +} +session_serialization false +]] + +---@return sidekick.cli.terminal.Cmd? +function M:terminal() + local layout = M.tpl + layout = layout:gsub("{cmd}", self.tool.cmd[1]) + layout = layout:gsub("{name}", self.tool.name) + if #self.tool.cmd == 1 then + layout = layout:gsub("{args}", "") + else + local args = vim.list_slice(self.tool.cmd, 2) + layout = layout:gsub("{args}", "args " .. table.concat( + vim.tbl_map(function(a) + return ("%q"):format(a) + end, args), + " " + )) --[[@as string]] + end + + local layout_file = Config.state("zellij-layout-" .. self.id .. ".kdl") + vim.fn.writefile(vim.split(layout, "\n"), layout_file) + Util.set_state(self.id, { tool = self.tool.name, cwd = self.cwd }) + + return { + cmd = { "zellij", "--layout", layout_file, "attach", "--create", self.id }, + env = { + ZELLIJ = false, + ZELLIJ_SESSION_NAME = false, + ZELLIJ_PANE_ID = false, + }, + } +end + +---@return sidekick.cli.terminal.Cmd? +function M:attach() + if not self.started and vim.env.ZELLIJ and Config.cli.mux.create ~= "terminal" then + Util.warn({ + ("Zellij does not support `opts.cli.mux.create = %q`."):format(Config.cli.mux.create), + ("Falling back to `%q`."):format("terminal"), + "Please update your config.", + }) + end + do + -- Zellij's scripting API is too limited, so + -- always run embedded sessions + return self:terminal() + end + + -- if self.started then + -- if self.sid == self.mux_session then + -- return { + -- cmd = { "zellij", "attach", self.sid }, + -- env = { + -- ZELLIJ = false, + -- ZELLIJ_SESSION_NAME = false, + -- ZELLIJ_PANE_ID = false, + -- }, + -- } + -- end + -- return -- nothing to do + -- end + -- + -- if Config.cli.mux.create == "terminal" or vim.env.ZELLIJ == nil then + -- return self:terminal() + -- elseif Config.cli.mux.create == "split" then + -- local cmd = { "zellij", "run", "--cwd", self.cwd, "-d" } + -- cmd[#cmd + 1] = Config.cli.mux.split.vertical and "right" or "down" + -- -- local size = Config.cli.mux.split.size + -- -- vim.list_extend(cmd, { "-l", tostring(size <= 1 and ((size * 100) .. "%") or size) }) + -- + -- cmd[#cmd + 1] = "--" + -- vim.list_extend(cmd, self.tool.cmd) + -- Util.exec(cmd) + -- end +end + +function M.sessions() + local sessions = Util.exec({ "zellij", "list-sessions", "-ns" }, { notify = false }) or {} + local ret = {} ---@type sidekick.cli.session.State[] + + for _, s in ipairs(sessions) do + local state = Util.get_state(s) + if state then + ret[#ret + 1] = { + id = s, + cwd = state.cwd, + tool = state.tool, + mux_session = s, + } + end + end + + return ret +end + +return M diff --git a/lua/sidekick/cli/state.lua b/lua/sidekick/cli/state.lua new file mode 100644 index 0000000..1fa224a --- /dev/null +++ b/lua/sidekick/cli/state.lua @@ -0,0 +1,185 @@ +local Config = require("sidekick.config") +local Session = require("sidekick.cli.session") +local Terminal = require("sidekick.cli.terminal") +local Util = require("sidekick.util") + +local M = {} + +---@class sidekick.cli.State +---@field tool sidekick.cli.Tool +---@field session? sidekick.cli.Session +---@field installed? boolean +---@field started? boolean +---@field attached? boolean +---@field terminal? sidekick.cli.Terminal + +---@class sidekick.cli.Filter +---@field attached? boolean +---@field cwd? boolean +---@field installed? boolean +---@field name? string +---@field session? string +---@field started? boolean +---@field terminal? boolean + +---@class sidekick.cli.With +---@field filter? sidekick.cli.Filter +---@field show? boolean +---@field focus? boolean +---@field create? boolean +---@field all? boolean +---@field state? sidekick.cli.State + +---@param t sidekick.cli.State +---@param filter? sidekick.cli.Filter +function M.is(t, filter) + filter = filter or {} + return (filter.attached == nil or filter.attached == t.attached) + and (filter.cwd == nil or (t.session and t.session.cwd == Session.cwd())) + and (filter.installed == nil or filter.installed == t.installed) + and (filter.name == nil or filter.name == t.tool.name) + and (filter.session == nil or (t.session and t.session.id == filter.session)) + and (filter.started == nil or filter.started == t.started) + and (filter.terminal == nil or filter.terminal == (t.terminal ~= nil)) +end + +---@param session sidekick.cli.Session +function M.get_state(session) + return { + tool = session.tool, + session = session, + installed = true, -- it's running, so it must be installed + started = session.started, + attached = session.attached, + terminal = session.backend == "terminal" and Terminal.get(session.id) or nil, + } +end + +---@param filter? sidekick.cli.Filter +---@return sidekick.cli.State[] +function M.get(filter) + local all = {} ---@type sidekick.cli.State[] + local sids = {} ---@type table + local sessions = Session.sessions() + local terminals = {} ---@type table + + for _, s in pairs(sessions) do + if s.backend == "terminal" then + terminals[s.id] = true + end + end + + for _, s in pairs(sessions) do + local skip = false + if s.backend ~= "terminal" and s.mux_session == s.sid and terminals[s.sid] then + -- ignore non-terminal sessions that have a terminal session with the same mux_session + -- this avoids showing both a tmux/zellij session and the terminal session attached to it + skip = true + end + if not skip then + sids[s.sid] = true + all[#all + 1] = M.get_state(s) + end + end + + for name, tool in pairs(Config.tools()) do + local sid = Session.sid({ tool = name }) + if not sids[sid] then + all[#all + 1] = { + tool = tool, + installed = vim.fn.executable(tool.cmd[1]) == 1, + } + end + end + + local cwd = Session.cwd() + + ---@type sidekick.cli.State[] + ---@param t sidekick.cli.State + local ret = vim.tbl_filter(function(t) + return M.is(t, filter) + end, all) + table.sort(ret, function(a, b) + if a.installed ~= b.installed then + return a.installed + end + -- sessions in cwd, or tools without a session + local a_cwd = (not a.session or a.session.cwd == cwd or false) + local b_cwd = (not b.session or b.session.cwd == cwd or false) + if a_cwd ~= b_cwd then + return a_cwd + end + if a.started ~= b.started then + return a.started + end + if a.attached ~= b.attached then + return a.attached + end + if (a.terminal ~= nil) ~= (b.terminal ~= nil) then + return a.terminal ~= nil + end + return a.tool.name < b.tool.name + end) + return ret +end + +---@param cb fun(state: sidekick.cli.State) +---@param ... sidekick.cli.With +function M.with(cb, ...) + local todo = { {} } ---@type sidekick.cli.With[] + for i = 1, select("#", ...) do + local o = select(i, ...) + if type(o) == "table" then + todo[#todo + 1] = o + end + end + local opts = vim.tbl_deep_extend("force", unpack(todo)) ---@type sidekick.cli.With + cb = vim.schedule_wrap(cb) + local filter = vim.deepcopy(opts.filter or {}) + filter.attached = true + local tools = opts.state and { opts.state } or M.get(filter) + tools = opts.all and tools or { tools[1] } -- FIXME: should be last used + if #tools == 0 and opts.create then + require("sidekick.cli.ui.select").select({ + auto = true, + filter = opts.filter, + cb = function(state) + if not state then + return + end + cb(M.attach(state, { show = opts.show, focus = opts.focus })) + end, + }) + else + vim.tbl_map(cb, tools) + end +end + +---@param state sidekick.cli.State +---@param opts? {show?:boolean, focus?:boolean} +function M.attach(state, opts) + opts = opts or {} + local tool = state.tool + if vim.fn.executable(tool.cmd[1]) == 0 then + Util.error(("`%s` is not installed"):format(tool.cmd[1])) + return + end + local session = state.session or Session.new({ tool = tool.name }) + session = Session.attach(session) + state = M.get_state(session) + local terminal = state.terminal + if terminal then + if opts.show then + terminal:show() + if opts.focus ~= false and terminal:is_running() then + terminal:focus() + end + state = M.get_state(session) + end + else + Util.info("Attached to `" .. state.tool.name .. "`") + end + return state +end + +return M diff --git a/lua/sidekick/cli/terminal.lua b/lua/sidekick/cli/terminal.lua index 6a94433..7b69903 100644 --- a/lua/sidekick/cli/terminal.lua +++ b/lua/sidekick/cli/terminal.lua @@ -1,11 +1,13 @@ local Config = require("sidekick.config") -local Mux = require("sidekick.cli.mux") local Session = require("sidekick.cli.session") local Util = require("sidekick.util") ----@class sidekick.cli.Terminal ----@field tool sidekick.cli.Tool ----@field session sidekick.cli.Session +---@class sidekick.cli.terminal.Cmd +---@field name string Name of the tool +---@field cmd string[] Command to run the CLI tool +---@field env? table Environment variables to set when running the command + +---@class sidekick.cli.Terminal: sidekick.cli.Session ---@field opts sidekick.win.Opts ---@field group integer ---@field ctime integer @@ -16,7 +18,6 @@ local Util = require("sidekick.util") ---@field job? integer ---@field buf? integer ---@field win? integer ----@field mux? sidekick.cli.Muxer local M = {} M.__index = M @@ -82,35 +83,35 @@ function M.get(session_id) return M.terminals[session_id] end +---@return sidekick.cli.session.State[] function M.sessions() - local ret = {} ---@type table - for _, t in pairs(M.terminals) do - ret[t.session.id] = t.session - end - return ret + return vim.tbl_values(M.terminals) end ----@param tool sidekick.cli.Tool -function M.new(tool) - local existing = tool.session and M.get(tool.session.id) - if existing then - return existing - end - local self = setmetatable({}, M) - self.tool = tool +---@param opts sidekick.cli.session.Opts +function M.new(opts) + opts.backend = "terminal" + return Session.new(opts) --[[@as sidekick.cli.Terminal]] +end + +function M:init() self.opts = vim.deepcopy(Config.cli.win) self.ctime = vim.uv.hrtime() - self.session = tool.session or Session.new(tool) self.atime = self.ctime self.send_queue = {} - self.group = vim.api.nvim_create_augroup("sidekick_cli_" .. self.session.id, { clear = true }) - M.terminals[self.session.id] = self + self.attached = true + self.group = vim.api.nvim_create_augroup("sidekick_cli_" .. self.id, { clear = true }) + M.terminals[self.id] = self if Config.cli.win.config then Config.cli.win.config(self) end return self end +function M:attach() + self.attached = true +end + function M:is_running() return self.job and vim.fn.jobwait({ self.job }, 0)[1] == -1 end @@ -128,26 +129,16 @@ function M:start() return end - if Config.cli.mux.enabled then - self.mux = Mux.new(self.tool, self.session) - if not self.mux then - return - end - Session.save(self.session) - end - - local cmd = self.mux and self.mux:cmd() or self.tool - self.buf = vim.api.nvim_create_buf(false, true) for k, v in pairs(merge(vim.deepcopy(bo), self.opts.bo)) do ---@diagnostic disable-next-line: no-unknown vim.bo[self.buf][k] = v end - vim.b[self.buf].sidekick_cli = self.tool.name + vim.b[self.buf].sidekick_cli = self.tool local Actions = require("sidekick.cli.actions") - ---@type table + ---@type table local keys = vim.tbl_extend("force", {}, self.opts.keys, self.tool.keys or {}) for name, km in pairs(keys) do if type(km) == "table" then @@ -213,18 +204,19 @@ function M:start() end, }) - local norm_cmd = vim.deepcopy(cmd.cmd) ---@type string|string[] + local norm_cmd = vim.deepcopy(self.tool.cmd) ---@type string|string[] if vim.fn.has("win32") == 1 then local cmd1 = vim.fn.exepath(norm_cmd[1]) - if cmd1 == "" or not cmd1:find("%.exe") then - norm_cmd = table.concat(cmd.cmd, " ") + if cmd1 == "" or not cmd1:find("%.exe$") then + norm_cmd = table.concat(self.tool.cmd, " ") else norm_cmd[1] = cmd1 end end vim.api.nvim_win_call(self.win, function() - local env = vim.tbl_extend("force", {}, vim.uv.os_environ(), self.tool.env or {}, cmd.env or {}, { + ---@type table + local env = vim.tbl_extend("force", {}, vim.uv.os_environ(), self.tool.config.env or {}, self.tool.env or {}, { NVIM = vim.v.servername, NVIM_LISTEN_ADDRESS = false, NVIM_LOG_FILE = false, @@ -239,7 +231,7 @@ function M:start() end end self.job = vim.fn.jobstart(norm_cmd, { - cwd = self.session.cwd, + cwd = self.cwd, term = true, clear_env = true, env = not vim.tbl_isempty(env) and env or nil, @@ -247,11 +239,13 @@ function M:start() end) if self.job <= 0 then - local display = table.concat(cmd.cmd, " ") + local display = table.concat(self.tool.cmd, " ") Util.error("Failed to run `" .. display .. "`") self:close() return end + self.started = true + self.attached = true self.timer = vim.uv.new_timer() self.timer:start(INITIAL_SEND_DELAY, SEND_DELAY, function() @@ -348,7 +342,7 @@ function M:hide() end function M:close() - M.terminals[self.session.id] = nil + M.terminals[self.id] = nil if vim.tbl_isempty(M.terminals) then require("sidekick.cli.watch").disable() end @@ -362,7 +356,6 @@ function M:close() vim.fn.jobstop(self.job) self.job = nil end - self.mux = nil if self.buf and vim.api.nvim_buf_is_valid(self.buf) then vim.api.nvim_buf_delete(self.buf, { force = true }) self.buf = nil diff --git a/lua/sidekick/cli/tool.lua b/lua/sidekick/cli/tool.lua new file mode 100644 index 0000000..dcecd8e --- /dev/null +++ b/lua/sidekick/cli/tool.lua @@ -0,0 +1,53 @@ +local Config = require("sidekick.config") + +---@class sidekick.cli.Tool: sidekick.cli.Config +---@field config sidekick.cli.Config +---@field name string +local M = {} +M.__index = M + +---@type table +local base = setmetatable({}, { + __index = function(t, key) + local f = vim.api.nvim_get_runtime_file("sk/cli/" .. key .. ".lua", false)[1] + if f then + local ok, ret = pcall(dofile, f) + if ok and type(ret) == "table" then + rawset(t, key, ret) + end + end + return rawget(t, key) + end, +}) + +---@param name string +function M.get(name) + local config = + vim.tbl_deep_extend("force", vim.deepcopy(base[name] or {}), vim.deepcopy(Config.cli.tools[name] or {})) + local self = setmetatable(vim.deepcopy(config), M) --[[@as sidekick.cli.Tool]] + self.config = config + self.is_proc = nil + self.name = name + return self +end + +---@param proc sidekick.cli.Proc +function M:is_proc(proc) + local is_proc = self.config.is_proc + if type(is_proc) == "string" then + local re = vim.regex(is_proc) + is_proc = function(_, p) + return re:match_str(p.cmd) ~= nil + end + self.config.is_proc = is_proc + end + return type(is_proc) == "function" and is_proc(self, proc) or false +end + +---@param opts? sidekick.cli.Config +function M:clone(opts) + local clone = vim.tbl_deep_extend("force", vim.deepcopy(self), opts or {}) + return setmetatable(clone, M) --[[@as sidekick.cli.Tool]] +end + +return M diff --git a/lua/sidekick/cli/ui/prompt.lua b/lua/sidekick/cli/ui/prompt.lua new file mode 100644 index 0000000..7926649 --- /dev/null +++ b/lua/sidekick/cli/ui/prompt.lua @@ -0,0 +1,105 @@ +---@module 'snacks' + +local Config = require("sidekick.config") +local Context = require("sidekick.cli.context") + +local M = {} + +---@class sidekick.cli.Prompt +---@field cb fun(msg?:string) + +---@param opts sidekick.cli.Prompt +function M.select(opts) + assert(type(opts) == "table", "opts must be a table") + local prompts = vim.tbl_keys(Config.cli.prompts) ---@type string[] + table.sort(prompts) + local context = Context.get() + local test = Context.get() + test.get = function(_, name) + if name == "nl" then + return { { { "\\n", "@string.escape" } } } + end + return { { { ("{%s}"):format(name), "Special" } } } + end + + local items = {} ---@type snacks.picker.finder.Item[] + for _, name in ipairs(prompts) do + local prompt = Config.cli.prompts[name] or {} + prompt = type(prompt) == "string" and { msg = prompt } or prompt + prompt = type(prompt) == "function" and { msg = "[function]" } or prompt + + ---@cast prompt sidekick.Prompt + prompt.msg = prompt.msg or "" + local text, rendered = context:render({ prompt = name }) + if rendered and #rendered > 0 then + local extmarks = {} ---@type snacks.picker.Extmark[] + for l, line in ipairs(rendered) do + local col = 0 + for _, hl in ipairs(line) do + if hl[1] then + if hl[2] then + extmarks[#extmarks + 1] = { + row = l, + col = col, + end_col = col + #hl[1], + hl_group = hl[2], + } + end + col = col + #hl[1] + end + end + end + ---@class sidekick.select_prompt.Item: snacks.picker.finder.Item + items[#items + 1] = { + text = name, + data = text, + name = name, + prompt = prompt, + preview = { + text = text, + extmarks = extmarks, + }, + } + end + end + + ---@type snacks.picker.ui_select.Opts + local select_opts = { + prompt = "Select a prompt", + ---@param item sidekick.select_prompt.Item + format_item = function(item, is_snacks) + if is_snacks then + local ret = {} ---@type snacks.picker.Highlight[] + ret[#ret + 1] = { item.name, "Title" } + ret[#ret + 1] = { string.rep(" ", 18 - #item.name) } + local _, prompt = test:render({ msg = item.prompt.msg:gsub("\n", "{nl}"), this = false }) + vim.list_extend(ret, prompt and prompt[1] or {}) + return ret + end + return ("[%s] %s"):format(item.name, string.rep(" ", 18 - #item.name) .. item.prompt.msg) + end, + picker = { + preview = "preview", + layout = { + preset = "vscode", + min_height = 0.6, + preview = true, + }, + win = { + input = { + keys = { + [""] = { "yank", mode = { "n", "i" } }, + ["y"] = { "yank" }, + }, + }, + }, + }, + } + + ---@param choice? sidekick.select_prompt.Item + vim.ui.select(items, select_opts, function(choice) + return opts.cb(choice and choice.preview.text or nil) + end) +end + +return M diff --git a/lua/sidekick/cli/ui/select.lua b/lua/sidekick/cli/ui/select.lua new file mode 100644 index 0000000..f035f86 --- /dev/null +++ b/lua/sidekick/cli/ui/select.lua @@ -0,0 +1,115 @@ +local Util = require("sidekick.util") + +---@class sidekick.cli.Select: sidekick.cli.With +---@field cb fun(state?:sidekick.cli.State) +---@field auto? boolean Automatically select if only one tool matches the filter + +local M = {} + +---@param opts sidekick.cli.Select +function M.select(opts) + assert(type(opts) == "table", "opts must be a table") + local tools = require("sidekick.cli.state").get(opts.filter) + + ---@param state? sidekick.cli.State + local on_select = function(state) + if state and not state.installed then + M.on_missing(state.tool) + state = nil + end + opts.cb(state) + end + + if #tools == 0 then + Util.warn("No tools match the given filter") + return + elseif #tools == 1 and opts.auto then + on_select(tools[1]) + return + end + + ---@type snacks.picker.ui_select.Opts + local select_opts = { + prompt = "Select CLI tool:", + picker = { format = M.format }, + kind = "snacks", + ---@param tool sidekick.cli.State + format_item = function(tool, is_snacks) + local parts = M.format(tool) + return is_snacks and parts or table.concat(vim.tbl_map(function(p) + return p[1] + end, parts)) + end, + } + + vim.ui.select(tools, select_opts, on_select) +end + +---@param tool sidekick.cli.Tool +function M.on_missing(tool) + Util.error(("Tool `%s` is not installed"):format(tool.name)) + if tool.url then + local ok, err = vim.ui.open(tool.url) + if ok then + Util.info(("Opening %s in your browser..."):format(tool.url)) + else + Util.error(("Failed to open %s: %s"):format(tool.url, err)) + end + end +end + +---@param state sidekick.cli.State|snacks.picker.Item +---@param picker? snacks.Picker +function M.format(state, picker) + local sw = vim.api.nvim_strwidth + local ret = {} ---@type snacks.picker.Highlight[] + + local status = state.terminal and "terminal" + or state.attached and "attached" + or state.started and "started" + or state.installed and "installed" + or "missing" + + ---@type table + local icons = { + terminal = { "󰆍 ", "SidekickCliTerminal" }, + attached = { " ", "SidekickCliAttached" }, + started = { " ", "SidekickCliStarted" }, + installed = { " ", "SidekickCliInstalled" }, + missing = { " ", "SidekickCliMissing" }, + } + + if picker then + local count = picker:count() + local idx = tostring(state.idx) + idx = (" "):rep(#tostring(count) - #idx) .. idx + ret[#ret + 1] = { idx .. ".", "SnacksPickerIdx" } + ret[#ret + 1] = { " " } + end + ret[#ret + 1] = icons[status] + ret[#ret + 1] = { " " } + ret[#ret + 1] = { state.tool.name } + local len = sw(state.tool.name) + 2 + if state.session then + local b = state.session.mux_backend or state.session.backend + local backend = ("[%s]"):format(b) + if state.session.mux_session and state.session.mux_session ~= state.session.sid then + backend = ("[%s:%s]"):format(b, state.session.mux_session) + end + ret[#ret + 1] = { string.rep(" ", 12 - len) } + ret[#ret + 1] = { backend, "Special" } + len = 12 + sw(backend) + ret[#ret + 1] = { string.rep(" ", 40 - len) } + if picker then + local item = setmetatable({}, state) --[[@as snacks.picker.Item]] + item.file = state.session.cwd + item.dir = true + vim.list_extend(ret, require("snacks").picker.format.filename(item, picker)) + else + ret[#ret + 1] = { vim.fn.fnamemodify(state.session.cwd, ":p:~"), "Directory" } + end + end + return ret +end + +return M diff --git a/lua/sidekick/config.lua b/lua/sidekick/config.lua index 8785773..f7fb8d2 100644 --- a/lua/sidekick/config.lua +++ b/lua/sidekick/config.lua @@ -80,10 +80,19 @@ local defaults = { ---@class sidekick.cli.Mux ---@field backend? "tmux"|"zellij" Multiplexer backend to persist CLI sessions mux = { - backend = "zellij", + backend = "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 + -- 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" + split = { + vertical = true, -- vertical or horizontal split + size = 0.5, -- size of the split (0-1 for percentage) + }, }, - ---@type table + ---@type table tools = { aider = { cmd = { "aider" }, url = "https://github.com/Aider-AI/aider" }, amazon_q = { cmd = { "q" }, url = "https://github.com/aws/amazon-q-developer-cli" }, @@ -211,6 +220,19 @@ function M.get_client(buf) return M.get_clients({ bufnr = buf or 0 })[1] end +---@param name string +function M.get_tool(name) + return require("sidekick.cli.tool").get(name) +end + +function M.tools() + local ret = {} ---@type table + for name in pairs(M.cli.tools) do + ret[name] = M.get_tool(name) + end + return ret +end + function M.set_hl() local links = { DiffContext = "DiffChange", @@ -218,6 +240,12 @@ function M.set_hl() DiffDelete = "DiffDelete", Sign = "Special", Chat = "NormalFloat", + CliMissing = "DiagnosticError", + CliAttached = "Special", + CliTerminal = "DiagnosticInfo", + CliStarted = "DiagnosticWarn", + CliInstalled = "DiagnosticOk", + CliUnavailable = "DiagnosticError", } for from, to in pairs(links) do vim.api.nvim_set_hl(0, "Sidekick" .. from, { link = to, default = true }) diff --git a/lua/sidekick/health.lua b/lua/sidekick/health.lua index 653c489..8aa3e35 100644 --- a/lua/sidekick/health.lua +++ b/lua/sidekick/health.lua @@ -76,8 +76,12 @@ function M.check() end end - for _, tool in ipairs(require("sidekick.cli").get_tools()) do - if tool.installed then + local tools = require("sidekick.config").tools() + local tool_names = vim.tbl_keys(tools) ---@type string[] + table.sort(tool_names) + for _, name in ipairs(tool_names) do + local tool = tools[name] + if vim.fn.executable(tool.cmd[1]) == 1 then ok("`" .. tool.name .. "` is installed") else warn("`" .. tool.name .. "` is not installed") diff --git a/lua/sidekick/util.lua b/lua/sidekick/util.lua index 32dd972..bfcb26c 100644 --- a/lua/sidekick/util.lua +++ b/lua/sidekick/util.lua @@ -1,29 +1,30 @@ local M = {} ----@param msg string +---@param msg string|string[] ---@param level? vim.log.levels function M.notify(msg, level) + msg = type(msg) == "table" and table.concat(msg, "\n") or msg vim.schedule(function() vim.notify(msg, level or vim.log.levels.INFO, { title = "Sidekick" }) end) end ----@param msg string +---@param msg string|string[] function M.info(msg) M.notify(msg, vim.log.levels.INFO) end ----@param msg string +---@param msg string|string[] function M.error(msg) M.notify(msg, vim.log.levels.ERROR) end ----@param msg string +---@param msg string|string[] function M.warn(msg) M.notify(msg, vim.log.levels.WARN) end ----@param msg string +---@param msg string|string[] function M.debug(msg) if require("sidekick.config").debug then M.warn(msg) @@ -153,4 +154,74 @@ function M.exec(cmd, opts) return vim.split(result.stdout, "\n", { plain = true, trimempty = true }) end +---@class sidekick.util.Curl +---@field method? "GET"|"POST"|"PUT"|"DELETE" HTTP method +---@field headers? table HTTP headers +---@field data? any Request body + +---@param url string +---@param opts? sidekick.util.Curl +---@return string? response +function M.curl(url, opts) + opts = opts or {} + + local cmd = { "curl", "-s", "-S" } + + if opts.method then + vim.list_extend(cmd, { "-X", opts.method }) + end + + for key, value in pairs(opts.headers or {}) do + vim.list_extend(cmd, { "-H", ("%s: %s"):format(key, value) }) + end + + -- Handle JSON data + if type(opts.data) == "string" then + vim.list_extend(cmd, { "-d", opts.data }) + elseif opts.data ~= nil then + local ok, json = pcall(vim.json.encode, opts.data) + if not ok then + M.error("Failed to encode JSON data") + return + end + vim.list_extend(cmd, { "-H", "Content-Type: application/json" }) + vim.list_extend(cmd, { "-d", json }) + end + + table.insert(cmd, url) + + local ret = M.exec(cmd) + return ret and table.concat(ret, "\n") or nil +end + +local state_dir = vim.fn.stdpath("state") .. "/sidekick" + +---@param key string +---@param value any +function M.set_state(key, value) + vim.fn.mkdir(state_dir, "p") + local path = state_dir .. "/" .. key .. ".json" + local ok, data = pcall(vim.json.encode, value) + if ok then + local f = io.open(path, "w") + if f then + f:write(data) + f:close() + end + end +end + +---@param key string +---@return any +function M.get_state(key) + local path = state_dir .. "/" .. key .. ".json" + local f = io.open(path, "r") + if f then + local data = f:read("*a") + f:close() + local ok, result = pcall(vim.json.decode, data) + return ok and result or nil + end +end + return M diff --git a/sk/cli/aider.lua b/sk/cli/aider.lua new file mode 100644 index 0000000..d5e14bb --- /dev/null +++ b/sk/cli/aider.lua @@ -0,0 +1,6 @@ +---@type sidekick.cli.Config +return { + cmd = { "aider" }, + is_proc = "\\", + url = "https://github.com/Aider-AI/aider" +} diff --git a/sk/cli/amazon_q.lua b/sk/cli/amazon_q.lua new file mode 100644 index 0000000..36b7e52 --- /dev/null +++ b/sk/cli/amazon_q.lua @@ -0,0 +1,6 @@ +---@type sidekick.cli.Config +return { + cmd = { "q" }, + is_proc = "\\", + url = "https://github.com/aws/amazon-q-developer-cli" +} diff --git a/sk/cli/claude.lua b/sk/cli/claude.lua new file mode 100644 index 0000000..4c967cd --- /dev/null +++ b/sk/cli/claude.lua @@ -0,0 +1,6 @@ +---@type sidekick.cli.Config +return { + cmd = { "claude" }, + is_proc = "\\", + url = "https://github.com/anthropics/claude-code" +} diff --git a/sk/cli/codex.lua b/sk/cli/codex.lua new file mode 100644 index 0000000..9e230d5 --- /dev/null +++ b/sk/cli/codex.lua @@ -0,0 +1,6 @@ +---@type sidekick.cli.Config +return { + cmd = { "codex", "--search" }, + is_proc = "\\", + url = "https://github.com/openai/codex" +} diff --git a/sk/cli/copilot.lua b/sk/cli/copilot.lua new file mode 100644 index 0000000..93d4b21 --- /dev/null +++ b/sk/cli/copilot.lua @@ -0,0 +1,6 @@ +---@type sidekick.cli.Config +return { + cmd = { "copilot", "--banner" }, + is_proc = "\\", + url = "https://github.com/github/copilot-cli" +} diff --git a/sk/cli/crush.lua b/sk/cli/crush.lua new file mode 100644 index 0000000..4b91186 --- /dev/null +++ b/sk/cli/crush.lua @@ -0,0 +1,9 @@ +---@type sidekick.cli.Config +return { + cmd = { "crush" }, + is_proc = "\\", + keys = { + prompt = { "", "prompt" } + }, + url = "https://github.com/charmbracelet/crush" +} diff --git a/sk/cli/cursor.lua b/sk/cli/cursor.lua new file mode 100644 index 0000000..73f2fb0 --- /dev/null +++ b/sk/cli/cursor.lua @@ -0,0 +1,6 @@ +---@type sidekick.cli.Config +return { + cmd = { "cursor-agent" }, + is_proc = "\\", + url = "https://cursor.com/cli" +} diff --git a/sk/cli/gemini.lua b/sk/cli/gemini.lua new file mode 100644 index 0000000..1472862 --- /dev/null +++ b/sk/cli/gemini.lua @@ -0,0 +1,6 @@ +---@type sidekick.cli.Config +return { + cmd = { "gemini" }, + is_proc = "\\", + url = "https://github.com/google-gemini/gemini-cli" +} diff --git a/sk/cli/grok.lua b/sk/cli/grok.lua new file mode 100644 index 0000000..c1eeec5 --- /dev/null +++ b/sk/cli/grok.lua @@ -0,0 +1,6 @@ +---@type sidekick.cli.Config +return { + cmd = { "grok" }, + is_proc = "\\", + url = "https://github.com/superagent-ai/grok-cli" +} diff --git a/sk/cli/opencode.lua b/sk/cli/opencode.lua new file mode 100644 index 0000000..e3e259e --- /dev/null +++ b/sk/cli/opencode.lua @@ -0,0 +1,75 @@ +---@class sidekick.cli.session.Opencode: sidekick.cli.Session +---@field port number +---@field base_url string +local M = {} +M.__index = M + +function M.sessions() + local Procs = require("sidekick.cli.procs") + local Util = require("sidekick.util") + + -- Get listening port for this PID + -- Get all listening ports with PIDs in one call + local lines = Util.exec({ "lsof", "-w", "-iTCP", "-sTCP:LISTEN", "-P", "-n", "-Fn", "-Fp" }, { notify = false }) or {} + + -- Parse lsof output to build pid -> port mapping + local ports = {} ---@type table + local current_pid ---@type number? + + for _, line in ipairs(lines) do + local pid = line:match("^p(%d+)$") + if pid then + current_pid = tonumber(pid) + else + local port = line:match("^n.*:(%d+)$") + if port and current_pid then + ports[current_pid] = tonumber(port) + end + end + end + + -- Find opencode processes and match with ports + local ret = {} ---@type sidekick.cli.session.State[] + + for _, proc in pairs(Procs:find("opencode")) do + local port = ports[proc.pid] + if port then + ret[#ret + 1] = { + id = "opencode-" .. proc.pid, + tool = "opencode", + cwd = proc.cwd, + port = port, + base_url = ("http://localhost:%d"):format(port), + } + end + end + return ret +end + +function M:attach() end + +function M:send(text) + require("sidekick.util").curl(self.base_url .. "/tui/append-prompt", { + method = "POST", + data = { text = text }, + }) +end + +function M:submit() + require("sidekick.util").curl(self.base_url .. "/tui/submit-prompt", { + method = "POST", + data = {}, + }) +end + +require("sidekick.cli.session").register("opencode", M) + +---@type sidekick.cli.Config +return { + cmd = { "opencode" }, + env = { + OPENCODE_THEME = "system", + }, + is_proc = "\\", + url = "https://github.com/sst/opencode", +} diff --git a/sk/cli/qwen.lua b/sk/cli/qwen.lua new file mode 100644 index 0000000..a9d2e8a --- /dev/null +++ b/sk/cli/qwen.lua @@ -0,0 +1,6 @@ +---@type sidekick.cli.Config +return { + cmd = { "qwen" }, + is_proc = "\\", + url = "https://github.com/QwenLM/qwen-code" +}