a new neovim integration for opencode β bringing opencode assistant to your favorite editor with a pure lua implementation.
π― TL;DR: this project is a fork of coder/claudecode.nvim. the reason? well, I've been using
claudecode.nvimdirectly and the experience is amazing with this plugin. after some time I'm testing opencode, and it seems the only existing integrations with it are not smooth enough in comparison to my previous claude code experience. so, to improve it, I made this fork, specially handling file reference with properly anchors (with text like@path/to/file#L1-L2) instead of what other opencode plugins does (refer just using plain text). also it's important to mention that implementation was made using opencode with kimi k2.5.
opencode-nvim.mov
the original implementation has a reverse-engineered version of claude code plugin for other IDEs, so that's one of the reasons why this experience was so smooth.
- pure lua, zero dependencies β built entirely with
vim.loopand neovim built-ins - built with AI β used opencode to make this implementation
- proper file handling - as opencode still doesn't support websocket connection (that's an issue and this feature is in development right now) all handling here doesn't have the smoothiest experience, but it seems better integrated for simple usage (as I prefer to use a vanilla setup for my coding experience), making correctly file handling and interacting better with terminal
{
"lanjoni/opencode.nvim",
dependencies = { "folke/snacks.nvim" },
config = true,
keys = {
{ "<leader>a", nil, desc = "AI/OpenCode" },
{ "<leader>ac", "<cmd>OpenCode<cr>", desc = "Toggle OpenCode" },
{ "<leader>af", "<cmd>OpenCodeFocus<cr>", desc = "Focus OpenCode" },
{ "<leader>ar", "<cmd>OpenCode --resume<cr>", desc = "Resume OpenCode" },
{ "<leader>aC", "<cmd>OpenCode --continue<cr>", desc = "Continue OpenCode" },
{ "<leader>ab", "<cmd>OpenCodeAdd %<cr>", desc = "Add current buffer" },
{ "<leader>as", "<cmd>OpenCodeSend<cr>", mode = "v", desc = "Send to OpenCode" },
{
"<leader>as",
"<cmd>OpenCodeTreeAdd<cr>",
desc = "Add file",
ft = { "NvimTree", "neo-tree", "oil", "minifiles", "netrw" },
},
-- Diff management
{ "<leader>aa", "<cmd>OpenCodeDiffAccept<cr>", desc = "Accept diff" },
{ "<leader>ad", "<cmd>OpenCodeDiffDeny<cr>", desc = "Deny diff" },
},
}that's it! the plugin will auto-configure everything else.
- neovim >= 0.8.0
- opencode installed
- folke/snacks.nvim for enhanced terminal support
if you have your opencode in some different place that you may want to refer (or even a local development branch) you can use a local installation path.
configure the plugin with the direct path:
{
"lanjoni/opencode.nvim",
dependencies = { "folke/snacks.nvim" },
opts = {
terminal_cmd = "~/.opencode/local/opencode", -- Point to local installation
},
config = true,
keys = {
-- Your keymaps here
},
}Note: if opencode was installed globally via npm, you can use the default configuration without specifying
terminal_cmd.
" Launch OpenCode in a split
:OpenCode
" OpenCode now sees your current file and selections in real-time!
" Send visual selection as context
:'<,'>OpenCodeSend
" OpenCode can open files, show diffs, and more- launch opencode: run
:OpenCodeto open opencode in a split terminal (I like to use the default settings pressing<leader>ac - send context:
- select text in visual mode and use
<leader>asto send it to opencode - in
nvim-tree/neo-tree/oil.nvim/mini.nvim, press<leader>ason a file to add it to opencode's context
- select text in visual mode and use
- let opencode work: opencode can now:
- see your current file and selections in real-time
- open files in your editor
- show diffs with proposed changes
- access diagnostics and workspace info
also to keep things minimal, I removed the model selection to make it directly to opencode. the implementation can be done in future but for now I usually recommend selecting models and more inside the opencode interface.
:OpenCode- toggle the opencode terminal window:OpenCodeFocus- smart focus/toggle opencode terminal:OpenCodeSend- send current visual selection to opencode:OpenCodeAdd <file-path> [start-line] [end-line]- add specific file to opencode context with optional line range:OpenCodeDiffAccept- accept diff changes:OpenCodeDiffDeny- reject diff changes
when opencode proposes changes, the plugin opens a native Neovim diff view:
- Accept:
:w(save) or<leader>aa - Reject:
:qor<leader>ad
you can edit opencode's suggestions before accepting them.
this plugin integrates with opencode using terminal-based communication. when you launch opencode, the plugin opens it in a dedicated terminal and communicates via stdin injection.
- terminal launch: opens opencode in a split terminal with
--portflag for http api access - autocomplete simulation: sends
@filenamevia terminal input to trigger opencode's autocomplete - file references: selects files from autocomplete to create styled file parts
- context sharing: sends visual selections and file references directly to the prompt
when opencode implements proper IDE integration api:
- websocket or sse for bidirectional communication
- structured message support (filepart, agentpart, etc.)
- lock file system at
~/.opencode/ide/[port].lock - mcp tool implementation
the current terminal-based approach provides immediate functionality while we await official websocket api support from opencode.
built with pure lua and zero external dependencies:
- terminal integration - Direct terminal control with
jobstart()and stdin injection - autocomplete simulation - Triggers opencode's TUI autocomplete via keystrokes
- port management - HTTP API port tracking for future enhancements
- selection tracking - Real-time context updates
- native diff support - Seamless file comparison
- WIP: websocket server - RFC 6455 compliant implementation (for future opencode API)
for deep technical details, see ARCHITECTURE.md.
{
"lanjoni/opencode.nvim",
dependencies = { "folke/snacks.nvim" },
opts = {
-- Server Configuration
port_range = { min = 10000, max = 65535 },
auto_start = true,
log_level = "info", -- "trace", "debug", "info", "warn", "error"
terminal_cmd = nil, -- Custom terminal command (default: "opencode")
-- For local installations: "~/.opencode/local/opencode"
-- For native binary: use output from 'which opencode'
-- Send/Focus Behavior
-- When true, successful sends will focus the opencode terminal if already connected
focus_after_send = false,
-- Selection Tracking
track_selection = true,
visual_demotion_delay_ms = 50,
-- Terminal Configuration
terminal = {
split_side = "right", -- "left" or "right"
split_width_percentage = 0.30,
provider = "auto", -- "auto", "snacks", "native", "external", "none", or custom provider table
auto_close = true,
snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below
-- Provider-specific options
provider_opts = {
-- Command for external terminal provider. Can be:
-- 1. String with %s placeholder: "alacritty -e %s" (backward compatible)
-- 2. String with two %s placeholders: "alacritty --working-directory %s -e %s" (cwd, command)
-- 3. Function returning command: function(cmd, env) return "alacritty -e " .. cmd end
external_terminal_cmd = nil,
},
},
-- Diff Integration
diff_opts = {
layout = "vertical", -- "vertical" or "horizontal"
open_in_new_tab = false,
keep_terminal_focus = false, -- if true, moves focus back to terminal after diff opens
hide_terminal_in_new_tab = false,
-- on_new_file_reject = "keep_empty", -- "keep_empty" or "close_window"
-- Legacy aliases (still supported):
-- vertical_split = true,
-- open_in_current_tab = true,
},
},
keys = {
-- Your keymaps here
},
}you can fix the opencode terminal's working directory regardless of autochdir and buffer-local cwd changes. Options (precedence order):
cwd_provider(ctx): function that returns a directory string. Receives{ file, file_dir, cwd }.cwd: static path to use as working directory.git_repo_cwd = true: resolves git root from the current file directory (or cwd if no file).
examples:
require("opencode").setup({
-- Top-level aliases are supported and forwarded to terminal config
git_repo_cwd = true,
})
require("opencode").setup({
terminal = {
cwd = vim.fn.expand("~/projects/my-app"),
},
})
require("opencode").setup({
terminal = {
cwd_provider = function(ctx)
-- Prefer repo root; fallback to file's directory
local cwd = require("opencode.cwd").git_root(ctx.file_dir or ctx.cwd) or ctx.file_dir or ctx.cwd
return cwd
end,
},
})the snacks_win_opts configuration allows you to create floating opencode terminals:
{
"lanjoni/opencode.nvim",
dependencies = { "folke/snacks.nvim" },
keys = {
{ "<C-,>", "<cmd>OpenCodeFocus<cr>", desc = "OpenCode", mode = { "n", "x" } },
},
opts = {
terminal = {
snacks_win_opts = {
position = "float",
width = 0.9,
height = 0.9,
keys = {
hide = { "<C-,>", function(self) self:hide() end, mode = "t", desc = "Hide" },
},
},
},
},
}for complete configuration options, see:
run opencode without any terminal management inside Neovim. this is useful for advanced setups where you manage the CLI externally (tmux, kitty, separate terminal windows) while still using the WebSocket server and tools.
you have to take care of launching CC and connecting it to the IDE yourself. (e.g. opencode --ide or launching opencode and then selecting the IDE using the /ide command)
{
"lanjoni/opencode.nvim",
opts = {
terminal = {
provider = "none", -- no UI actions; server + tools remain available
},
},
}notes:
- no windows/buffers are created.
:OpenCodeand related commands will not open anything. - the websocket server still starts and broadcasts work as usual. launch the opencode CLI externally when desired.
run opencode in a separate terminal application outside of Neovim:
-- Using a string template (simple)
{
"lanjoni/opencode.nvim",
opts = {
terminal = {
provider = "external",
provider_opts = {
external_terminal_cmd = "alacritty -e %s", -- %s is replaced with opencode command
-- Or with working directory: "alacritty --working-directory %s -e %s" (first %s = cwd, second %s = command)
},
},
},
}
-- Using a function for dynamic command generation (advanced)
{
"lanjoni/opencode.nvim",
opts = {
terminal = {
provider = "external",
provider_opts = {
external_terminal_cmd = function(cmd, env)
-- You can build complex commands based on environment or conditions
if vim.fn.has("mac") == 1 then
return { "osascript", "-e", string.format('tell app "Terminal" to do script "%s"', cmd) }
else
return "alacritty -e " .. cmd
end
end,
},
},
},
}You can create custom terminal providers by passing a table with the required functions instead of a string provider name:
require("opencode").setup({
terminal = {
provider = {
-- Required functions
setup = function(config)
-- Initialize your terminal provider
end,
open = function(cmd_string, env_table, effective_config, focus)
-- Open terminal with command and environment
-- focus parameter controls whether to focus terminal (defaults to true)
end,
close = function()
-- Close the terminal
end,
simple_toggle = function(cmd_string, env_table, effective_config)
-- Simple show/hide toggle
end,
focus_toggle = function(cmd_string, env_table, effective_config)
-- Smart toggle: focus terminal if not focused, hide if focused
end,
get_active_bufnr = function()
-- Return terminal buffer number or nil
return 123 -- example
end,
is_available = function()
-- Return true if provider can be used
return true
end,
-- Optional functions (auto-generated if not provided)
toggle = function(cmd_string, env_table, effective_config)
-- Defaults to calling simple_toggle for backward compatibility
end,
_get_terminal_for_test = function()
-- For testing only, defaults to return nil
return nil
end,
},
},
})here's a complete example using a hypothetical my_terminal plugin:
local my_terminal_provider = {
setup = function(config)
-- Store config for later use
self.config = config
end,
open = function(cmd_string, env_table, effective_config, focus)
if focus == nil then focus = true end
local my_terminal = require("my_terminal")
my_terminal.open({
cmd = cmd_string,
env = env_table,
width = effective_config.split_width_percentage,
side = effective_config.split_side,
focus = focus,
})
end,
close = function()
require("my_terminal").close()
end,
simple_toggle = function(cmd_string, env_table, effective_config)
require("my_terminal").toggle()
end,
focus_toggle = function(cmd_string, env_table, effective_config)
local my_terminal = require("my_terminal")
if my_terminal.is_focused() then
my_terminal.hide()
else
my_terminal.focus()
end
end,
get_active_bufnr = function()
return require("my_terminal").get_bufnr()
end,
is_available = function()
local ok, _ = pcall(require, "my_terminal")
return ok
end,
}
require("opencode").setup({
terminal = {
provider = my_terminal_provider,
},
})the custom provider will automatically fall back to the native provider if validation fails or is_available() returns false.
note: if your command or working directory may contain spaces or special characters, prefer returning a table of args from a function (e.g., { "alacritty", "--working-directory", cwd, "-e", "opencode", "--help" }) to avoid shell-quoting issues.
using auto-save plugins can cause diff windows opened by opencode to immediately accept without waiting for input. you can avoid this using a custom condition:
Pocco81/auto-save.nvim
opts = {
-- ... other options
condition = function(buf)
local fn = vim.fn
local utils = require("auto-save.utils.data")
-- First check the default conditions
if not (fn.getbufvar(buf, "&modifiable") == 1 and utils.not_in(fn.getbufvar(buf, "&filetype"), {})) then
return false
end
-- Exclude opencode diff buffers by buffer name patterns
local bufname = vim.api.nvim_buf_get_name(buf)
if bufname:match("%(proposed%)") or
bufname:match("%(NEW FILE %- proposed%)") or
bufname:match("%(New%)") then
return false
end
-- Exclude by buffer variables (opencode sets these)
if vim.b[buf].opencode_diff_tab_name or
vim.b[buf].opencode_diff_new_win or
vim.b[buf].opencode_diff_target_win then
return false
end
-- Exclude by buffer type (opencode diff buffers use "acwrite")
local buftype = fn.getbufvar(buf, "&buftype")
if buftype == "acwrite" then
return false
end
return true -- Safe to auto-save
end,
},okuuva/auto-save.nvim
opts = {
-- ... other options
condition = function(buf)
-- Exclude opencode diff buffers by buffer name patterns
local bufname = vim.api.nvim_buf_get_name(buf)
if bufname:match('%(proposed%)') or bufname:match('%(NEW FILE %- proposed%)') or bufname:match('%(New%)') then
return false
end
-- Exclude by buffer variables (opencode sets these)
if
vim.b[buf].opencode_diff_tab_name
or vim.b[buf].opencode_diff_new_win
or vim.b[buf].opencode_diff_target_win
then
return false
end
-- Exclude by buffer type (opencode diff buffers use "acwrite")
local buftype = vim.fn.getbufvar(buf, '&buftype')
if buftype == 'acwrite' then
return false
end
return true -- Safe to auto-save
end,
},- opencode not connecting? check
:OpenCodeStatusand verify lock file exists in~/.opencode/ide/ - need debug logs? set
log_level = "debug"in opts - terminal issues? try
provider = "native"if using snacks.nvim - local installation not working? if you used
opencode install, setterminal_cmd = "~/.opencode/local/opencode"in your config. checkwhich opencodevsls ~/.opencode/local/opencodeto verify your installation type. - native binary installation not working? if you used the alpha native binary installer, run
opencode doctorto verify installation health and usewhich opencodeto find the binary path. setterminal_cmd = "/path/to/opencode"with the detected path in your config.
run tests with make test or LUA_PATH="./lua/?.lua;./lua/?/init.lua;./?.lua;$LUA_PATH" busted tests/.