Skip to content
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ For me, the best tools are the ones that "just work." opencode.nvim is designed
- Accept/reject and reload OpenCode edits
- Handle OpenCode events as autocmds
- Simple, sensible, Vim-y defaults and interfaces
- **Live Context**: Broadcast current file/selection to OpenCode TUI in real-time via WebSocket

## 📦 Setup

Expand Down Expand Up @@ -56,12 +57,14 @@ vim.keymap.set({ "n" }, "<S-C-d>", function() require("opencode").command("
{
"nickjvandyke/opencode.nvim",
version = "*", -- Latest stable release
config = function()
init = function()
---@type opencode.Opts
vim.g.opencode_opts = {
-- Your configuration, if any; goto definition on the type for details
}
end,

config = function()
-- Recommended/example keymaps
vim.keymap.set({ "n", "x" }, "<C-a>", function() require("opencode").ask("@this: ") end, { desc = "Ask OpenCode…" })
vim.keymap.set({ "n", "x" }, "<C-x>", function() require("opencode").select() end, { desc = "Select OpenCode…" })
Expand Down Expand Up @@ -296,6 +299,41 @@ Prompt OpenCode.

Wraps Prompt as an operator, supporting ranges and dot-repeat.

### Live Context — `require("opencode").start_live_context()`

Keep OpenCode aware of the file and visual selection you are working in.

Set these options before the plugin loads. To start Live Context automatically, configure your plugin manager to load opencode.nvim at startup.

```lua
vim.g.opencode_opts = {
live_context = {
enabled = true, -- Start when the plugin loads
port = 0, -- Use an available port
auth_token = true, -- Optional: generate a per-process token
},
}

-- Or start manually with the configured options.
vim.keymap.set("n", "<leader>ol", function()
require("opencode").start_live_context()
end, { desc = "Start live context" })

vim.keymap.set("x", "<leader>oa", function()
require("opencode").attach_context()
end, { desc = "Attach selection to OpenCode" })
```

`attach_context()` emits `at_mentioned`. OpenCode inserts the file reference into its prompt without submitting it, leaving the prompt ready for more text.

| API or command | Description |
| --- | --- |
| `start_live_context(opts)` / `:OpenCodeLiveContextStart` | Start the server and selection tracking |
| `stop_live_context()` / `:OpenCodeLiveContextStop` | Stop the server and remove its lockfile |
| `attach_context()` / `:'<,'>OpenCodeAttach` | Insert the selected file range into the TUI prompt |

For a fixed port, set `port` and launch OpenCode with `OPENCODE_EDITOR_SSE_PORT` or `CLAUDE_CODE_SSE_PORT` set to the same value. This bypasses lockfile discovery.

### Command — `require("opencode").command()`

Command OpenCode:
Expand Down
73 changes: 73 additions & 0 deletions lua/opencode.lua
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,77 @@ end

M.format = require("opencode.context").format

M.editor = {
server = require("opencode.server.websocket"),
selection = require("opencode.editor.selection"),
lockfile = require("opencode.editor.lockfile"),
}

function M.start_live_context(opts)
opts = vim.tbl_deep_extend("force", {}, require("opencode.config").opts.live_context or {}, opts or {})
local port = opts.port or 0
local auth_token = opts.auth_token

if M.editor.server.is_running() then
return true, M.editor.server.get_port()
end

if auth_token == true then
local token_err
auth_token, token_err = M.editor.lockfile.generate_auth_token()
if not auth_token then
return false, "Failed to generate authentication token: " .. (token_err or "unknown error")
end
elseif auth_token ~= nil and type(auth_token) ~= "string" then
return false, "Authentication token must be a string or true"
end

M.editor.lockfile.clean_all()

local server_ok, server_result = M.editor.server.start(port, auth_token)
if not server_ok then
return false, server_result
end

local actual_port = server_result

local lock_ok, lock_result = M.editor.lockfile.create(actual_port, auth_token)
if not lock_ok then
M.editor.server.stop()
return false, lock_result
end

M.editor.selection.enable()

return true, actual_port
end

function M.stop_live_context()
local port = M.editor.server.get_port()

M.editor.selection.disable()
M.editor.server.stop()

if port then
M.editor.lockfile.remove(port)
end
end

function M.attach_context(line_start, line_end)
local selection = require("opencode.editor.selection")
local ok, err

if line_start and line_end then
ok, err = selection.send_range_as_mention(line_start, line_end)
else
ok, err = selection.send_visual_selection_as_mention()
end

if not ok then
vim.notify(err or "Failed to attach context", vim.log.levels.WARN, { title = "OpenCode" })
end

return ok, err
end

return M
11 changes: 11 additions & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
---@class opencode.editor.LiveContextOpts
---@field enabled? boolean Enable live context on startup (default: false)
---@field port? number WebSocket server port, 0 for random (default: 0)
---@field auth_token? string|true Authentication token, true to generate one (default: nil)

---@class opencode.Opts
---@field server? opencode.server.Opts OpenCode server connection options.
---@field contexts? table<string, fun(context: opencode.context.Context): string?> Context placeholders and their builders.
---@field ask? opencode.ask.Opts Options for `ask()`. Supports [snacks.input](https://github.com/folke/snacks.nvim/blob/main/docs/input.md).
---@field select? opencode.select.Opts Options and items for `select()`. Supports [snacks.picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md).
---@field events? opencode.events.Opts Options for handling OpenCode events.
---@field live_context? opencode.editor.LiveContextOpts Options for live context feature.

---Your opencode.nvim configuration.
---Passed via global variable for [simpler UX and faster startup](https://mrcjkb.dev/posts/2023-08-22-setup.html).
Expand Down Expand Up @@ -116,6 +122,11 @@ local defaults = {
},
},
},
live_context = {
enabled = false,
port = 0,
auth_token = nil,
},
}

---Plugin options, lazily merged from `defaults` and `vim.g.opencode_opts`.
Expand Down
1 change: 1 addition & 0 deletions lua/opencode/editor/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
return require("opencode.server.websocket")
154 changes: 154 additions & 0 deletions lua/opencode/editor/lockfile.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
local M = {}

local function get_lockfile_dir()
-- Opencode actually read the lockfile inside .claude
-- for some reason
-- See: https://github.com/anomalyco/opencode/blob/dev/packages/tui/src/editor.ts
return vim.fn.expand("~/.claude/ide")
end

local function get_lockfile_path(port)
return get_lockfile_dir() .. "/" .. port .. ".lock"
end

local function get_workspace_folders()
local folders = {}

local cwd = vim.fn.getcwd()
table.insert(folders, cwd)

return folders
end

function M.generate_auth_token()
local bytes, err = vim.uv.random(16)
if not bytes then
return nil, err or "Failed to obtain random bytes"
end

return (bytes:gsub(".", function(byte)
return string.format("%02x", string.byte(byte))
end))
end

function M.create(port, auth_token)
if not port or port <= 0 or port > 65535 then
return false, "Invalid port number"
end

if auth_token ~= nil and type(auth_token) ~= "string" then
return false, "Authentication token must be a string"
end

local lockfile_dir = get_lockfile_dir()
if vim.fn.mkdir(lockfile_dir, "p", "0700") == 0 and vim.fn.isdirectory(lockfile_dir) == 0 then
return false, "Failed to create lockfile directory"
end
pcall(vim.uv.fs_chmod, lockfile_dir, tonumber("700", 8))

local lockfile_path = get_lockfile_path(port)

local lock_content = {
pid = vim.fn.getpid(),
workspaceFolders = get_workspace_folders(),
ideName = "Neovim",
transport = "ws",
}

if auth_token then
lock_content.authToken = auth_token
end

local json = vim.json.encode(lock_content)

local temp_file = lockfile_path .. ".tmp." .. vim.fn.getpid()
local fd = io.open(temp_file, "wb")
if not fd then
return false, "Failed to create temporary lockfile"
end

fd:write(json)
fd:close()

local ok, err = os.rename(temp_file, lockfile_path)
if not ok then
os.remove(temp_file)
return false, "Failed to create lockfile: " .. (err or "unknown error")
end

return true, lockfile_path
end

function M.remove(port)
if not port then
return false
end

local lockfile_path = get_lockfile_path(port)

if vim.fn.filereadable(lockfile_path) == 1 then
os.remove(lockfile_path)
return true
end

return false
end

function M.exists(port)
if not port then
return false
end

local lockfile_path = get_lockfile_path(port)
return vim.fn.filereadable(lockfile_path) == 1
end

function M.read(port)
if not port then
return nil
end

local lockfile_path = get_lockfile_path(port)

if vim.fn.filereadable(lockfile_path) == 0 then
return nil
end

local fd = io.open(lockfile_path, "r")
if not fd then
return nil
end

local content = fd:read("*a")
fd:close()

local ok, data = pcall(vim.json.decode, content)
if not ok then
return nil
end

return data
end

function M.clean_all()
local lockfile_dir = get_lockfile_dir()

if vim.fn.isdirectory(lockfile_dir) == 0 then
return
end

local files = vim.fn.glob(lockfile_dir .. "/*.lock", true, true)
for _, file in ipairs(files) do
local port = vim.fn.fnamemodify(file, ":t:r")
local lock_data = M.read(tonumber(port))

if lock_data and lock_data.pid then
local pid = lock_data.pid
if not vim.uv.kill(pid, 0) then
os.remove(file)
end
end
end
end

return M
Loading
Loading