Skip to content

lanjoni/opencode.nvim

Β 
Β 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

124 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

opencode.nvim

tests neovim version status

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.nvim directly 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

what makes this special

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.loop and 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

installation

{
  "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.

requirements

local installation configuration

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.

configuring for local installation

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.

quick demo

" 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

usage

  1. launch opencode: run :OpenCode to open opencode in a split terminal (I like to use the default settings pressing <leader>ac
  2. send context:
    • select text in visual mode and use <leader>as to send it to opencode
    • in nvim-tree/neo-tree/oil.nvim/mini.nvim, press <leader>as on a file to add it to opencode's context
  3. 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.

key commands

  • :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

working with diffs

when opencode proposes changes, the plugin opens a native Neovim diff view:

  • Accept: :w (save) or <leader>aa
  • Reject: :q or <leader>ad

you can edit opencode's suggestions before accepting them.

how it works

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.

current implementation (terminal-based)

  1. terminal launch: opens opencode in a split terminal with --port flag for http api access
  2. autocomplete simulation: sends @filename via terminal input to trigger opencode's autocomplete
  3. file references: selects files from autocomplete to create styled file parts
  4. context sharing: sends visual selections and file references directly to the prompt

WIP: future websocket/sse support

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.

architecture

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.

advanced configuration

{
  "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
  },
}

working directory control

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,
  },
})

floating window configuration

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:

terminal providers

none (No-Op) provider

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. :OpenCode and related commands will not open anything.
  • the websocket server still starts and broadcasts work as usual. launch the opencode CLI externally when desired.

external terminal provider

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,
      },
    },
  },
}

custom terminal providers

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,
    },
  },
})

custom provider example

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.

auto-save plugin 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,
},

troubleshooting

  • opencode not connecting? check :OpenCodeStatus and 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, set terminal_cmd = "~/.opencode/local/opencode" in your config. check which opencode vs ls ~/.opencode/local/opencode to verify your installation type.
  • native binary installation not working? if you used the alpha native binary installer, run opencode doctor to verify installation health and use which opencode to find the binary path. set terminal_cmd = "/path/to/opencode" with the detected path in your config.

contributing

run tests with make test or LUA_PATH="./lua/?.lua;./lua/?/init.lua;./?.lua;$LUA_PATH" busted tests/.

license

MIT

acknowledgements

About

πŸ΄β€β˜ οΈ opencode neovim extension

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • Lua 97.6%
  • Shell 1.9%
  • Other 0.5%