diff --git a/README.md b/README.md index da70df9..0f034e4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # opencode.nvim -Integrate the [opencode](https://github.com/sst/opencode) AI assistant with Neovim — streamline editor-aware research, reviews, and requests. +Integrate the [opencode](https://github.com/sst/opencode) AI assistant with Neovim — streamline editor-aware research, reviews, and requests. -https://github.com/user-attachments/assets/01e4e2fc-bbfa-427e-b9dc-c1c1badaa90e + ## ✨ Features -- Auto-connects to *any* `opencode` running inside Neovim's CWD, or provides an integrated instance. +- Auto-connects to _any_ `opencode` running inside Neovim's CWD, or provides an integrated instance. - Input prompts with completions, highlights, and normal-mode support. - Select prompts from a library and define your own. - Inject relevant editor context (buffer, cursor, selection, diagnostics, etc.). @@ -89,6 +89,51 @@ vim.g.opencode_opts = { } ``` +#### [kitty](https://sw.kovidgoyal.net/kitty/) + +```lua +vim.g.opencode_opts = { + provider = { + enabled = "kitty", -- Default when running inside a `kitty` session with remote control enabled. + kitty = { + -- Location where `opencode` instance should be opened + -- Possible values: + -- * https://sw.kovidgoyal.net/kitty/launch/#cmdoption-launch-location + -- * `tab` + -- * `os-window` + location = "default", + -- Optional password for kitty remote control + -- https://sw.kovidgoyal.net/kitty/remote-control/#cmdoption-kitten-password + password = nil, + } + } +} +``` + +The kitty provider requires [remote control via a socket](https://sw.kovidgoyal.net/kitty/remote-control/#remote-control-via-a-socket) to be enabled. + +You can do this either by running Kitty with the following command: + +```bash +# For Linux only: +kitty -o allow_remote_control=yes --single-instance --listen-on unix:@mykitty + +# Other UNIX systems: +kitty -o allow_remote_control=yes --single-instance --listen-on unix:/tmp/mykitty +``` + +OR, by adding the following to your `kitty.conf`: + +``` +# For Linux only: +allow_remote_control yes +listen_on unix:@mykitty + +# Other UNIX systems: +allow_remote_control yes +listen_on unix:/tmp/kitty +``` + #### [tmux](https://github.com/tmux/tmux) ```lua @@ -129,6 +174,7 @@ Please submit PRs adding new providers! 🙂 ### ✍️ Ask — `require("opencode").ask()` Input a prompt to send to `opencode`. + - Press `` to browse recent asks. - Highlights contexts and `opencode` subagents. - Completes contexts and `opencode` subagents. @@ -140,6 +186,7 @@ Input a prompt to send to `opencode`. ### 📝 Select — `require("opencode").select()` Select from all `opencode.nvim` functionality. + - Fetches custom commands from `opencode`. image @@ -152,57 +199,57 @@ Send a prompt to `opencode`. Replaces placeholders in the prompt with the corresponding context: -| Placeholder | Context | -| - | - | -| `@this` | Visual selection if any, else cursor position | -| `@buffer` | Current buffer | -| `@buffers` | Open buffers | -| `@visible` | Visible text | -| `@diagnostics` | Current buffer diagnostics | -| `@quickfix` | Quickfix list | -| `@diff` | Git diff | -| `@grapple` | [grapple.nvim](https://github.com/cbochs/grapple.nvim) tags | +| Placeholder | Context | +| -------------- | ----------------------------------------------------------- | +| `@this` | Visual selection if any, else cursor position | +| `@buffer` | Current buffer | +| `@buffers` | Open buffers | +| `@visible` | Visible text | +| `@diagnostics` | Current buffer diagnostics | +| `@quickfix` | Quickfix list | +| `@diff` | Git diff | +| `@grapple` | [grapple.nvim](https://github.com/cbochs/grapple.nvim) tags | #### Prompts Reference a prompt by name to review, explain, and improve your code: -| Name | Prompt | -|------------------------------------|-----------------------------------------------------------| -| `ask` | *...* | -| `explain` | Explain `@this` and its context | -| `optimize` | Optimize `@this` for performance and readability | -| `document` | Add comments documenting `@this` | -| `test` | Add tests for `@this` | -| `review` | Review `@this` for correctness and readability | -| `diagnostics` | Explain `@diagnostics` | -| `fix` | Fix `@diagnostics` | -| `diff` | Review the following git diff for correctness and readability: `@diff` | -| `buffer` | `@buffer` | -| `this` | `@this` | +| Name | Prompt | +| ------------- | ---------------------------------------------------------------------- | +| `ask` | _..._ | +| `explain` | Explain `@this` and its context | +| `optimize` | Optimize `@this` for performance and readability | +| `document` | Add comments documenting `@this` | +| `test` | Add tests for `@this` | +| `review` | Review `@this` for correctness and readability | +| `diagnostics` | Explain `@diagnostics` | +| `fix` | Fix `@diagnostics` | +| `diff` | Review the following git diff for correctness and readability: `@diff` | +| `buffer` | `@buffer` | +| `this` | `@this` | ### 🧑‍🏫 Command — `require("opencode").command()` Send a command to `opencode`: -| Command | Description | -|-------------------------|----------------------------------------------------------| -| `session.list` | List sessions | -| `session.new` | Start a new session | -| `session.share` | Share the current session | -| `session.interrupt` | Interrupt the current session | -| `session.compact` | Compact the current session (reduce context size) | -| `session.page.up` | Scroll messages up by one page | -| `session.page.down` | Scroll messages down by one page | -| `session.half.page.up` | Scroll messages up by half a page | -| `session.half.page.down` | Scroll messages down by half a page | -| `session.first` | Jump to the first message in the session | -| `session.last` | Jump to the last message in the session | -| `session.undo` | Undo the last action in the current session | -| `session.redo` | Redo the last undone action in the current session | -| `prompt.submit` | Submit the TUI input | -| `prompt.clear` | Clear the TUI input | -| `agent.cycle` | Cycle the selected agent | +| Command | Description | +| ------------------------ | -------------------------------------------------- | +| `session.list` | List sessions | +| `session.new` | Start a new session | +| `session.share` | Share the current session | +| `session.interrupt` | Interrupt the current session | +| `session.compact` | Compact the current session (reduce context size) | +| `session.page.up` | Scroll messages up by one page | +| `session.page.down` | Scroll messages down by one page | +| `session.half.page.up` | Scroll messages up by half a page | +| `session.half.page.down` | Scroll messages down by half a page | +| `session.first` | Jump to the first message in the session | +| `session.last` | Jump to the last message in the session | +| `session.undo` | Undo the last action in the current session | +| `session.redo` | Redo the last undone action in the current session | +| `prompt.submit` | Submit the TUI input | +| `prompt.clear` | Clear the TUI input | +| `agent.cycle` | Cycle the selected agent | ## 👀 Events diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index f352cc1..e23caf8 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -127,6 +127,10 @@ local defaults = { -- Default to tmux if inside a tmux session return "tmux" end + if vim.env.KITTY_LISTEN_ON and #vim.env.KITTY_LISTEN_ON > 0 then + -- Default to kitty if inside a kitty session with remote control enabled + return "kitty" + end return false end)(), @@ -145,6 +149,9 @@ local defaults = { }, }, }, + kitty = { + location = "default", --"after" | "before" | "default" | "first" | "hsplit" | "last" | "neighbor" | "split" | "vsplit" | "tab" | "os-window" + }, tmux = { options = "-h", -- Open in a horizontal split }, diff --git a/lua/opencode/provider/init.lua b/lua/opencode/provider/init.lua index 06b5e31..aebe49b 100644 --- a/lua/opencode/provider/init.lua +++ b/lua/opencode/provider/init.lua @@ -25,10 +25,13 @@ ---@class opencode.provider.Opts --- ---The built-in provider to use, or `false` for none. ----Defaults to `"snacks"` if `snacks.terminal` is available, else `"tmux"` if in a `tmux` session, else `false`. ----@field enabled? "snacks"|"tmux"|false +---Defaults to `"snacks"` if `snacks.terminal` is available, +---else `"kitty"` if in a kitty session with remote control enabled, +---else `"tmux"` if in a `tmux` session, else `false`. +---@field enabled? "snacks"|"kitty"|"tmux"|false --- ---@field snacks? opencode.provider.snacks.Opts +---@field kitty? opencode.provider.kitty.Opts ---@field tmux? opencode.provider.tmux.Opts local M = {} diff --git a/lua/opencode/provider/kitty.lua b/lua/opencode/provider/kitty.lua new file mode 100644 index 0000000..f522e20 --- /dev/null +++ b/lua/opencode/provider/kitty.lua @@ -0,0 +1,208 @@ +---Provide `opencode` in a `kitty` terminal instance. +---Works only when kitty remote control is enabled. +---@class opencode.provider.Kitty : opencode.Provider +--- +---@field opts opencode.provider.kitty.Opts +---@field window_id? number The kitty window ID where `opencode` is running (internal use only) +local Kitty = {} +Kitty.__index = Kitty + +---@class opencode.provider.kitty.Opts +--- +---Location where `opencode` instance should be opened +---Possible values: +--- * https://sw.kovidgoyal.net/kitty/launch/#cmdoption-launch-location +--- * `tab` +--- * `os-window` +---@field location? "after" | "before" | "default" | "first" | "hsplit" | "last" | "neighbor" | "split" | "vsplit" | "tab" | "os-window" +--- +---Optional password for kitty remote control +---https://sw.kovidgoyal.net/kitty/remote-control/#cmdoption-kitten-password +---@field password? string +--- +---@param opts? opencode.provider.kitty.Opts +---@return opencode.provider.Kitty +function Kitty.new(opts) + local self = setmetatable({}, Kitty) + self.opts = vim.tbl_extend("keep", opts or {}, { + location = "default", + }) + self.window_id = nil + return self +end + +---Check if kitty remote control is enabled +function Kitty:check_kitty() + if not vim.env.KITTY_LISTEN_ON or #vim.env.KITTY_LISTEN_ON == 0 then + error( + "Kitty provider selected but KITTY_LISTEN_ON environment variable is not set. Enable remote control in kitty.", + 0 + ) + end +end + +---Execute a kitty remote control command +---@param args string[] Arguments to pass to kitty @ +---@return string|nil output, number|nil code +function Kitty:kitty_exec(args) + local cmd = { "kitty", "@" } + + -- Add password if configured + local password = self.opts.password or "" + if #password > 0 then + table.insert(cmd, "--password") + table.insert(cmd, password) + end + + -- Add the actual command arguments + for _, arg in ipairs(args) do + table.insert(cmd, arg) + end + + local output = vim.fn.system(cmd) + local code = vim.v.shell_error + + return output, code +end + +---Get the window ID where opencode is running +---@return number|nil window_id The kitty window ID +function Kitty:get_window_id() + -- Return cached window_id if it still exists + if self.window_id then + local _, code = self:kitty_exec({ "ls", "--match", "id:" .. self.window_id }) + if code == 0 then + return self.window_id -- Window still exists, return the cached ID + end + self.window_id = nil -- Window no longer exists + end + + -- Get all kitty windows and parse JSON + local output, code = self:kitty_exec({ "ls" }) + if code ~= 0 or not output then + return nil + end + + local ok, kitty_info = pcall(vim.json.decode, output) + if not ok or not kitty_info then + return nil + end + + -- Extract base command to search for (e.g., "opencode" from "opencode --some-flag") + local base_cmd = self.cmd:match("^%S+") + + local location = self.opts.location + local search_focused_os_window_only = location ~= "os-window" + local search_focused_tab_only = location ~= "tab" and search_focused_os_window_only + + -- Search for the window running opencode + for _, client in ipairs(kitty_info) do + -- Skip non-relevant clients when searching for the process in the same OS window + if search_focused_os_window_only and not client.is_focused then + goto continue_client + end + + for _, tab in ipairs(client.tabs or {}) do + -- Skip non-relevant tabs when searching for the process in the same tab + if search_focused_tab_only and not tab.is_focused then + goto continue_tab + end + + for _, window in ipairs(tab.windows or {}) do + for _, process in ipairs(window.foreground_processes or {}) do + for _, cmd_part in ipairs(process.cmdline or {}) do + if cmd_part:match(base_cmd) then + self.window_id = window.id + return window.id + end + end + end + end + + ::continue_tab:: + end + + ::continue_client:: + end + + return nil +end + +---Toggle opencode in kitty window +function Kitty:toggle() + self:check_kitty() + + local window_id = self:get_window_id() + if not window_id then + -- Create new window + self:start() + else + -- Close existing window + local _, code = self:kitty_exec({ "close-window", "--match", "id:" .. window_id }) + if code == 0 then + self.window_id = nil + end + end +end + +---Start opencode in kitty window +function Kitty:start() + self:check_kitty() + + local window_id = self:get_window_id() + if window_id then + vim.notify("An opencode instance is already running", vim.log.levels.INFO, { title = "opencode" }) + return + end + + local location = self.opts.location + local launch_cmd = { "launch", "--cwd=current", "--hold" } + + -- Input validation for `location` option + local VALID_LOCATIONS = { + "after", + "before", + "default", + "first", + "hsplit", + "last", + "neighbor", + "split", + "vsplit", + "tab", + "os-window", + } + + if not vim.tbl_contains(VALID_LOCATIONS, location) then + error(string.format("Invalid location '%s' specified", location), 0) + end + + -- Use `--location` for splits and `--type` for tab and os-window + if location == "tab" or location == "os-window" then + table.insert(launch_cmd, "--type=" .. location) + else + table.insert(launch_cmd, "--location=" .. location) + end + + table.insert(launch_cmd, self.cmd) + + local stdout, code = self:kitty_exec(launch_cmd) + + if code == 0 then + -- The window ID is returned directly in stdout + self.window_id = tonumber(stdout) + end +end + +---Show opencode window +function Kitty:show() + local window_id = self:get_window_id() + if not window_id then + vim.notify("No opencode instance is currently running", vim.log.levels.WARN, { title = "opencode" }) + return + end + + self:kitty_exec({ "focus-window", "--match", "id:" .. window_id }) +end + +return Kitty