From 8d8f3a34945bc0de834467ab066b752f5eda9c38 Mon Sep 17 00:00:00 2001 From: itsmeyaw Date: Fri, 7 Aug 2026 17:32:08 +0200 Subject: [PATCH 1/9] feat: add live context WebSocket server for real-time editor integration - Implement pure Lua WebSocket server (RFC 6455) with no external dependencies - Add selection tracking with autocommands and debounced updates - Create lockfile manager for editor discovery (~/.claude/ide/[port].lock) - Add configuration options for live context feature - Provide user commands and API for programmatic control - Support both auto-start and manual activation modes - Broadcast selection_changed and at_mentioned events to OpenCode TUI - Compatible with Claude Code WebSocket protocol Components: - WebSocket server (lua/opencode/editor/) - TCP server using vim.uv - Frame encoder/decoder - Handshake with auth - Client connection management - Pure Lua utilities (SHA-1, base64, UTF-8) - Selection tracking (lua/opencode/editor/selection.lua) - CursorMoved, ModeChanged, BufEnter, TextChanged autocommands - 100ms debounced updates - Lockfile manager (lua/opencode/editor/lockfile.lua) - Atomic file creation - Stale lockfile cleanup API: - start_live_context(opts) - stop_live_context() - attach_context() Commands: - :OpenCodeLiveContextStart - :OpenCodeLiveContextStop - :OpenCodeAttach (visual mode) Configuration: - live_context.enabled (default: false) - live_context.port (default: 0 for random) - live_context.auth_token (optional) --- LIVE_CONTEXT.md | 189 +++++++++++++++++++++ README.md | 36 ++++ lua/opencode.lua | 48 ++++++ lua/opencode/config.lua | 34 ++++ lua/opencode/editor/client.lua | 93 +++++++++++ lua/opencode/editor/frame.lua | 137 ++++++++++++++++ lua/opencode/editor/handshake.lua | 64 ++++++++ lua/opencode/editor/init.lua | 170 +++++++++++++++++++ lua/opencode/editor/lockfile.lua | 132 +++++++++++++++ lua/opencode/editor/selection.lua | 264 ++++++++++++++++++++++++++++++ lua/opencode/editor/tcp.lua | 57 +++++++ lua/opencode/editor/utils.lua | 157 ++++++++++++++++++ plugin/live-context.lua | 17 ++ 13 files changed, 1398 insertions(+) create mode 100644 LIVE_CONTEXT.md create mode 100644 lua/opencode/editor/client.lua create mode 100644 lua/opencode/editor/frame.lua create mode 100644 lua/opencode/editor/handshake.lua create mode 100644 lua/opencode/editor/init.lua create mode 100644 lua/opencode/editor/lockfile.lua create mode 100644 lua/opencode/editor/selection.lua create mode 100644 lua/opencode/editor/tcp.lua create mode 100644 lua/opencode/editor/utils.lua create mode 100644 plugin/live-context.lua diff --git a/LIVE_CONTEXT.md b/LIVE_CONTEXT.md new file mode 100644 index 00000000..9196530b --- /dev/null +++ b/LIVE_CONTEXT.md @@ -0,0 +1,189 @@ +# OpenCode.nvim Live Context Implementation + +## Overview + +This implementation adds real-time WebSocket-based live context broadcasting to opencode.nvim, enabling OpenCode TUI to track the current file/selection in Neovim. + +## Architecture + +### Components + +1. **WebSocket Server** (`lua/opencode/editor/`) + - `init.lua` - Main server module with start/stop/broadcast functions + - `tcp.lua` - TCP server using `vim.uv` + - `frame.lua` - WebSocket frame encoder/decoder (RFC 6455) + - `handshake.lua` - WebSocket upgrade handshake with auth + - `client.lua` - Client connection management + - `utils.lua` - Pure Lua utilities (SHA-1, base64, UTF-8) + +2. **Selection Tracking** (`lua/opencode/editor/selection.lua`) + - Autocommands for CursorMoved, ModeChanged, BufEnter, TextChanged + - Debounced updates (100ms) + - Visual selection and cursor position capture + +3. **Lockfile Manager** (`lua/opencode/editor/lockfile.lua`) + - Creates `~/.claude/ide/[port].lock` + - Compatible with Claude Code lockfile format + - Atomic file creation + - Auto-cleanup of stale lockfiles + +### Protocol + +**JSON-RPC 2.0 Messages:** + +```json +// Selection Changed (Neovim -> OpenCode) +{ + "jsonrpc": "2.0", + "method": "selection_changed", + "params": { + "text": "selected text", + "filePath": "/path/to/file", + "fileUrl": "file:///path/to/file", + "selection": { + "start": { "line": 10, "character": 5 }, + "end": { "line": 15, "character": 20 }, + "isEmpty": false + } + } +} + +// At-Mentioned (Neovim -> OpenCode) +{ + "jsonrpc": "2.0", + "method": "at_mentioned", + "params": { + "filePath": "/path/to/file", + "lineStart": 10, + "lineEnd": 20 + } +} +``` + +## Usage + +### Configuration + +```lua +vim.g.opencode_opts = { + live_context = { + enabled = true, -- Auto-start on VimEnter + port = 0, -- 0 for random port + auth_token = nil -- Optional: set to string or true to generate + } +} +``` + +### Commands + +- `:OpenCodeLiveContextStart` - Start WebSocket server +- `:OpenCodeLiveContextStop` - Stop WebSocket server +- `:'<,'>OpenCodeAttach` - Attach visual selection (no submit) + +### API + +```lua +local opencode = require("opencode") + +-- Start live context +opencode.start_live_context({ port = 0, auth_token = nil }) + +-- Stop live context +opencode.stop_live_context() + +-- Attach current selection without submitting +opencode.attach_context() +``` + +### Keymaps (Recommended) + +```lua +vim.keymap.set("n", "ol", function() + require("opencode").start_live_context() +end, { desc = "Start OpenCode live context" }) + +vim.keymap.set("x", "oa", function() + require("opencode").attach_context() +end, { desc = "Attach selection to OpenCode" }) +``` + +## OpenCode TUI Integration + +OpenCode TUI automatically discovers the WebSocket server via: + +1. **Lockfile** at `~/.claude/ide/[port].lock` + ```json + { + "pid": 12345, + "workspaceFolders": ["/path/to/project"], + "ideName": "Neovim", + "transport": "ws", + "authToken": "optional-token" + } + ``` + +2. **Environment Variable**: `OPENCODE_EDITOR_SSE_PORT` or `CLAUDE_CODE_SSE_PORT` + +## Implementation Notes + +### No External Dependencies + +All WebSocket code is pure Lua using Neovim's `vim.uv` (libuv) - no external Lua packages required. + +### Compatibility + +- Compatible with Claude Code's WebSocket protocol +- Uses same lockfile location and format +- Can share environment variables for discovery + +### Performance + +- Debounced updates (100ms) prevent excessive broadcasts +- Lightweight frame encoding without heavy abstraction +- Connection cleanup on Neovim exit + +### Future Enhancements + +1. **MCP Tools**: Expose tools that OpenCode can invoke + - `openFile` - Open file in Neovim + - `getCurrentSelection` - Get current selection + - `openDiff` - Show diff view + +2. **Bidirectional Communication** + - Handle tool calls from OpenCode + - Progress notifications for long operations + +3. **Multi-client Support** + - Connect multiple OpenCode TUI instances + - Per-client selection state + +## Files Created + +``` +lua/opencode/editor/ +├── init.lua # WebSocket server +├── tcp.lua # TCP server +├── frame.lua # WebSocket frames +├── handshake.lua # WebSocket handshake +├── client.lua # Client management +├── utils.lua # Utilities +├── selection.lua # Selection tracking +└── lockfile.lua # Lockfile manager + +plugin/ +└── live-context.lua # User commands +``` + +## Testing + +```bash +# Start OpenCode TUI +opencode + +# In Neovim (another terminal) +nvim +:OpenCodeLiveContextStart + +# OpenCode TUI should show "Connected to editor: Neovim" +# Move cursor in Neovim - TUI shows current file/line +``` diff --git a/README.md b/README.md index 256084b4..146588fa 100644 --- a/README.md +++ b/README.md @@ -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 @@ -296,6 +297,41 @@ Prompt OpenCode. Wraps Prompt as an operator, supporting ranges and dot-repeat. +### Live Context — `require("opencode").start_live_context()` + +Enable real-time broadcasting of file/selection context to OpenCode TUI via WebSocket. + +The OpenCode TUI automatically discovers and connects to: +1. Lockfile at `~/.claude/ide/[port].lock` +2. Environment variable `OPENCODE_EDITOR_SSE_PORT` or `CLAUDE_CODE_SSE_PORT` + +**Setup:** + +```lua +vim.g.opencode_opts = { + live_context = { + enabled = true, -- Auto-start on VimEnter + port = 0, -- 0 for random port + auth_token = nil -- Optional: set to string or true to generate + } +} + +-- Or start manually: +vim.keymap.set("n", "ol", function() + require("opencode").start_live_context() +end, { desc = "Start live context" }) +``` + +**Usage:** +- OpenCode TUI will show current file selection in real-time +- Use `attach_context()` to send visual selection without submitting prompt +- Selection updates broadcast automatically when you move cursor or change files + +**API:** +- `start_live_context(opts)` - Start WebSocket server and selection tracking +- `stop_live_context()` - Stop server and cleanup +- `attach_context()` - Send current selection to OpenCode (no submit) + ### Command — `require("opencode").command()` Command OpenCode: diff --git a/lua/opencode.lua b/lua/opencode.lua index 8cef9bb0..833d282c 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -110,4 +110,52 @@ end M.format = require("opencode.context").format +M.editor = { + server = require("opencode.editor"), + selection = require("opencode.editor.selection"), + lockfile = require("opencode.editor.lockfile"), +} + +function M.start_live_context(opts) + opts = opts or {} + local port = opts.port or 0 + local auth_token = opts.auth_token + + 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() + local selection = require("opencode.editor.selection") + + if not selection.send_visual_selection_as_mention() then + vim.notify("No selection to attach", vim.log.levels.WARN, { title = "OpenCode" }) + end +end + return M diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index ab6f80a8..79158b29 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -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 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). @@ -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`. @@ -129,4 +140,27 @@ if M.opts.events.reload.enabled then end end +if M.opts.live_context and M.opts.live_context.enabled then + vim.api.nvim_create_autocmd("VimEnter", { + group = vim.api.nvim_create_augroup("OpenCodeLiveContextAutoStart", { clear = true }), + callback = function() + local opencode = require("opencode") + local ok, result = opencode.start_live_context(M.opts.live_context) + if ok then + vim.notify( + "OpenCode live context started on port " .. result, + vim.log.levels.INFO, + { title = "OpenCode" } + ) + else + vim.notify( + "Failed to start OpenCode live context: " .. result, + vim.log.levels.ERROR, + { title = "OpenCode" } + ) + end + end, + }) +end + return M diff --git a/lua/opencode/editor/client.lua b/lua/opencode/editor/client.lua new file mode 100644 index 00000000..c461fa4e --- /dev/null +++ b/lua/opencode/editor/client.lua @@ -0,0 +1,93 @@ +local M = {} +local uv = vim.uv +local frame_module = require("opencode.editor.frame") + +local Client = {} +Client.__index = Client + +function Client.new(tcp_client) + local self = setmetatable({}, Client) + self.client = tcp_client + self.buffer = "" + self.state = "handshake" + self.authenticated = false + return self +end + +function Client:send(data) + if self.client and not self.client:is_closing() then + self.client:write(data) + end +end + +function Client:send_json(message) + local json = vim.json.encode(message) + local ws_frame = frame_module.text_frame(json) + self:send(ws_frame) +end + +function Client:close() + if self.client and not self.client:is_closing() then + if self.state == "connected" then + self:send(frame_module.close_frame()) + end + self.client:close() + end + self.state = "closed" + self.client = nil +end + +function Client:handle_data(data, on_message, on_close) + self.buffer = self.buffer .. data + + while #self.buffer > 0 do + if self.state == "handshake" then + local headers_end = self.buffer:find("\r\n\r\n") + if not headers_end then + break + end + + local request = self.buffer:sub(1, headers_end + 3) + self.buffer = self.buffer:sub(headers_end + 4) + + local ok, result = pcall(on_message, "handshake", request) + if not ok then + self:close() + on_close() + return + end + elseif self.state == "connected" then + local decoded = frame_module.decode_frame(self.buffer) + if not decoded then + break + end + + self.buffer = self.buffer:sub(decoded.consumed + 1) + + if decoded.opcode == 0x8 then + self:close() + on_close() + return + elseif decoded.opcode == 0x9 then + self:send(frame_module.pong_frame()) + elseif decoded.opcode == 0xA then + elseif decoded.opcode == 0x1 then + local ok, err = pcall(on_message, "message", decoded.payload) + if not ok then + end + end + else + break + end + end +end + +function Client:set_state(state) + self.state = state +end + +function Client:is_connected() + return self.state == "connected" and self.client and not self.client:is_closing() +end + +return M diff --git a/lua/opencode/editor/frame.lua b/lua/opencode/editor/frame.lua new file mode 100644 index 00000000..4b16eee4 --- /dev/null +++ b/lua/opencode/editor/frame.lua @@ -0,0 +1,137 @@ +local M = {} +local utils = require("opencode.editor.utils") + +local OPCODE_CONTINUATION = 0x0 +local OPCODE_TEXT = 0x1 +local OPCODE_BINARY = 0x2 +local OPCODE_CLOSE = 0x8 +local OPCODE_PING = 0x9 +local OPCODE_PONG = 0xA + +function M.decode_frame(data) + if #data < 2 then + return nil + end + + local byte1 = string.byte(data, 1) + local byte2 = string.byte(data, 2) + + local fin = (byte1 & 0x80) ~= 0 + local rsv1 = (byte1 & 0x40) ~= 0 + local rsv2 = (byte1 & 0x20) ~= 0 + local rsv3 = (byte1 & 0x10) ~= 0 + local opcode = byte1 & 0x0F + + local masked = (byte2 & 0x80) ~= 0 + local payload_len = byte2 & 0x7F + + local offset = 2 + + if payload_len == 126 then + if #data < offset + 2 then + return nil + end + payload_len = string.unpack(">I2", data, offset + 1) + offset = offset + 2 + elseif payload_len == 127 then + if #data < offset + 8 then + return nil + end + payload_len = string.unpack(">I8", data, offset + 1) + offset = offset + 8 + end + + local mask_key = nil + if masked then + if #data < offset + 4 then + return nil + end + mask_key = string.sub(data, offset + 1, offset + 4) + offset = offset + 4 + end + + if #data < offset + payload_len then + return nil + end + + local payload = string.sub(data, offset + 1, offset + payload_len) + + if masked and mask_key then + local decoded = {} + for i = 1, #payload do + local byte = string.byte(payload, i) + local mask_byte = string.byte(mask_key, ((i - 1) % 4) + 1) + table.insert(decoded, string.char(byte ~ mask_byte)) + end + payload = table.concat(decoded) + end + + return { + fin = fin, + opcode = opcode, + payload = payload, + consumed = offset + payload_len, + } +end + +function M.encode_frame(payload, opcode, masked) + opcode = opcode or OPCODE_TEXT + masked = masked or false + + local len = #payload + local frame = {} + + local byte1 = 0x80 | opcode + table.insert(frame, string.char(byte1)) + + local byte2 = masked and 0x80 or 0x00 + + if len <= 125 then + table.insert(frame, string.char(byte2 | len)) + elseif len <= 65535 then + table.insert(frame, string.char(byte2 | 126)) + table.insert(frame, string.pack(">I2", len)) + else + table.insert(frame, string.char(byte2 | 127)) + table.insert(frame, string.pack(">I8", len)) + end + + if masked then + local mask_key = {} + for _ = 1, 4 do + table.insert(mask_key, string.char(math.random(0, 255))) + end + local mask_str = table.concat(mask_key) + table.insert(frame, mask_str) + + local masked_payload = {} + for i = 1, len do + local byte = string.byte(payload, i) + local mask_byte = string.byte(mask_str, ((i - 1) % 4) + 1) + table.insert(masked_payload, string.char(byte ~ mask_byte)) + end + table.insert(frame, table.concat(masked_payload)) + else + table.insert(frame, payload) + end + + return table.concat(frame) +end + +function M.close_frame() + return M.encode_frame("", OPCODE_CLOSE, false) +end + +function M.ping_frame() + return M.encode_frame("", OPCODE_PING, false) +end + +function M.pong_frame() + return M.encode_frame("", OPCODE_PONG, false) +end + +function M.text_frame(payload) + return M.encode_frame(payload, OPCODE_TEXT, false) +end + +return M diff --git a/lua/opencode/editor/handshake.lua b/lua/opencode/editor/handshake.lua new file mode 100644 index 00000000..5afb95b3 --- /dev/null +++ b/lua/opencode/editor/handshake.lua @@ -0,0 +1,64 @@ +local M = {} +local utils = require("opencode.editor.utils") + +function M.validate_upgrade_request(request, expected_auth_token) + local headers = utils.parse_http_headers(request) + + if not headers["upgrade"] or headers["upgrade"]:lower() ~= "websocket" then + return false, "Missing or invalid Upgrade header" + end + + if not headers["connection"] or not headers["connection"]:lower():find("upgrade") then + return false, "Missing or invalid Connection header" + end + + if not headers["sec-websocket-version"] or headers["sec-websocket-version"] ~= "13" then + return false, "Missing or invalid Sec-WebSocket-Version header" + end + + if not headers["sec-websocket-key"] then + return false, "Missing Sec-WebSocket-Key header" + end + + if expected_auth_token then + local auth_header = headers["x-claude-code-ide-authorization"] or headers["x-opencode-ide-authorization"] + if not auth_header then + return false, "Missing authentication header" + end + + if not utils.constant_time_compare(auth_header, expected_auth_token) then + return false, "Invalid authentication token" + end + end + + return true, headers +end + +function M.create_accept_key(websocket_key) + local GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + local combined = websocket_key .. GUID + local sha1_hash = utils.sha1(combined) + return utils.base64_encode(sha1_hash) +end + +function M.create_response(websocket_key, auth_token) + local accept_key = M.create_accept_key(websocket_key) + + local response = { + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Accept: " .. accept_key, + } + + if auth_token then + table.insert(response, "X-OpenCode-IDE-Authorization: " .. auth_token) + end + + table.insert(response, "") + table.insert(response, "") + + return table.concat(response, "\r\n") +end + +return M diff --git a/lua/opencode/editor/init.lua b/lua/opencode/editor/init.lua new file mode 100644 index 00000000..06b51303 --- /dev/null +++ b/lua/opencode/editor/init.lua @@ -0,0 +1,170 @@ +local M = {} +local Client = require("opencode.editor.client") +local handshake = require("opencode.editor.handshake") +local tcp_server = require("opencode.editor.tcp") + +M.state = { + server = nil, + clients = {}, + port = nil, + auth_token = nil, +} + +local function remove_client(client) + for i, c in ipairs(M.state.clients) do + if c == client then + table.remove(M.state.clients, i) + break + end + end +end + +local function broadcast(method, params) + local message = { + jsonrpc = "2.0", + method = method, + params = params or {}, + } + + for _, client in ipairs(M.state.clients) do + if client:is_connected() then + client:send_json(message) + end + end +end + +function M.start(port, auth_token) + if M.state.server then + return false, "Server already running on port " .. M.state.port + end + + port = port or 0 + auth_token = auth_token or nil + + local server, err = tcp_server.create_server("127.0.0.1", port, function(tcp_client) + local client = Client.new(tcp_client) + client.buffer = "" + + local function handle_message(type, data) + if type == "handshake" then + local ok, headers = handshake.validate_upgrade_request(data, auth_token) + if not ok then + local response = "HTTP/1.1 400 Bad Request\r\n\r\n" .. (headers or "Invalid handshake") + client:send(response) + client:close() + remove_client(client) + return + end + + local ws_key = headers["sec-websocket-key"] + local response = handshake.create_response(ws_key, auth_token) + client:send(response) + client:set_state("connected") + client.authenticated = auth_token and true or false + + table.insert(M.state.clients, client) + elseif type == "message" then + local ok, message = pcall(vim.json.decode, data) + if ok and message then + if message.method == "initialize" then + local response = { + jsonrpc = "2.0", + id = message.id, + result = { + protocolVersion = "2025-11-25", + serverInfo = { + name = "opencode.nvim", + version = "1.0.0", + }, + }, + } + client:send_json(response) + end + end + end + end + + local function handle_close() + remove_client(client) + end + + tcp_client:read_start(function(err, data) + if err or not data then + client:close() + remove_client(client) + return + end + + client:handle_data(data, handle_message, handle_close) + end) + end) + + if not server then + return false, err or "Failed to create server" + end + + local sockname = server:getsockname() + local actual_port = sockname.port + + M.state.server = server + M.state.port = actual_port + M.state.auth_token = auth_token + + return true, actual_port +end + +function M.stop() + for _, client in ipairs(M.state.clients) do + client:close() + end + M.state.clients = {} + + if M.state.server then + M.state.server:close() + M.state.server = nil + end + + M.state.port = nil + M.state.auth_token = nil +end + +function M.is_running() + return M.state.server ~= nil +end + +function M.get_port() + return M.state.port +end + +function M.get_auth_token() + return M.state.auth_token +end + +function M.broadcast_selection_changed(file_path, selection) + broadcast("selection_changed", { + text = selection.text or "", + filePath = file_path, + fileUrl = "file://" .. file_path, + selection = { + start = { + line = selection.start_line, + character = selection.start_col, + }, + ["end"] = { + line = selection.end_line, + character = selection.end_col, + }, + isEmpty = selection.is_empty or false, + }, + }) +end + +function M.broadcast_at_mentioned(file_path, line_start, line_end) + broadcast("at_mentioned", { + filePath = file_path, + lineStart = line_start, + lineEnd = line_end, + }) +end + +return M diff --git a/lua/opencode/editor/lockfile.lua b/lua/opencode/editor/lockfile.lua new file mode 100644 index 00000000..e5f36ccb --- /dev/null +++ b/lua/opencode/editor/lockfile.lua @@ -0,0 +1,132 @@ +local M = {} + +local function get_lockfile_path(port) + local home = vim.fn.expand("~") + local lockfile_dir = home .. "/.claude/ide" + + vim.fn.mkdir(lockfile_dir, "p") + + return 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.create(port, auth_token) + if not port or port <= 0 or port > 65535 then + return false, "Invalid port number" + end + + 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" + local fd = io.open(temp_file, "w") + 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 home = vim.fn.expand("~") + local lockfile_dir = home .. "/.claude/ide" + + 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.loop.kill(pid, 0) then + os.remove(file) + end + end + end +end + +return M diff --git a/lua/opencode/editor/selection.lua b/lua/opencode/editor/selection.lua new file mode 100644 index 00000000..759e6cc5 --- /dev/null +++ b/lua/opencode/editor/selection.lua @@ -0,0 +1,264 @@ +local M = {} +local server = require("opencode.editor.server") + +M.state = { + last_selection = nil, + enabled = false, + debounce_timer = nil, +} + +local function get_visual_selection() + local bufnr = vim.api.nvim_get_current_buf() + local mode = vim.fn.mode() + + if not mode:match("[vV\22]") then + return nil + end + + local start_pos = vim.api.nvim_buf_get_mark(bufnr, "<") + local end_pos = vim.api.nvim_buf_get_mark(bufnr, ">") + + if not start_pos or not end_pos then + return nil + end + + if start_pos[1] > end_pos[1] or (start_pos[1] == end_pos[1] and start_pos[2] > end_pos[2]) then + start_pos, end_pos = end_pos, start_pos + end + + local start_line = start_pos[1] + local start_col = start_pos[2] + 1 + local end_line = end_pos[1] + local end_col = end_pos[2] + 1 + + local text = "" + local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) + if #lines > 0 then + if start_line == end_line then + text = string.sub(lines[1] or "", start_col, end_col) + else + lines[1] = string.sub(lines[1] or "", start_col) + lines[#lines] = string.sub(lines[#lines] or "", 1, end_col) + text = table.concat(lines, "\n") + end + end + + return { + start_line = start_line - 1, + start_col = start_col - 1, + end_line = end_line - 1, + end_col = end_col - 1, + text = text, + is_empty = #text == 0, + } +end + +local function get_cursor_position() + local bufnr = vim.api.nvim_get_current_buf() + local cursor = vim.api.nvim_win_get_cursor(0) + local line = cursor[1] + local col = cursor[2] + + local line_text = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or "" + local char = string.sub(line_text, col + 1, col + 1) + + return { + start_line = line - 1, + start_col = col, + end_line = line - 1, + end_col = col, + text = char, + is_empty = true, + } +end + +local function get_current_selection() + local bufnr = vim.api.nvim_get_current_buf() + local filepath = vim.api.nvim_buf_get_name(bufnr) + + if filepath == "" or not filepath:match("^/") then + return nil + end + + local mode = vim.fn.mode() + local selection + + if mode:match("[vV\22]") then + selection = get_visual_selection() + else + selection = get_cursor_position() + end + + if not selection then + return nil + end + + return { + file_path = filepath, + selection = selection, + } +end + +local function selection_key(selection) + if not selection then + return "" + end + return string.format( + "%s:%d:%d-%d:%d", + selection.file_path, + selection.selection.start_line, + selection.selection.start_col, + selection.selection.end_line, + selection.selection.end_col + ) +end + +local function has_selection_changed(current) + if not M.state.last_selection then + return true + end + + local last_key = selection_key(M.state.last_selection) + local current_key = selection_key(current) + + return last_key ~= current_key +end + +local function send_selection_update() + if not server.is_running() then + return + end + + local current = get_current_selection() + + if current and has_selection_changed(current) then + server.broadcast_selection_changed(current.file_path, current.selection) + M.state.last_selection = current + end +end + +local function debounce_send_selection() + if M.state.debounce_timer then + M.state.debounce_timer:stop() + M.state.debounce_timer:close() + M.state.debounce_timer = nil + end + + M.state.debounce_timer = vim.uv.new_timer() + M.state.debounce_timer:start(100, 0, vim.schedule_wrap(function() + send_selection_update() + if M.state.debounce_timer then + M.state.debounce_timer:close() + M.state.debounce_timer = nil + end + end)) +end + +local function on_cursor_moved() + if not M.state.enabled then + return + end + debounce_send_selection() +end + +local function on_mode_changed() + if not M.state.enabled then + return + end + debounce_send_selection() +end + +local function on_buf_enter() + if not M.state.enabled then + return + end + debounce_send_selection() +end + +local function on_text_changed() + if not M.state.enabled then + return + end + debounce_send_selection() +end + +function M.enable() + if M.state.enabled then + return + end + + M.state.enabled = true + + local group = vim.api.nvim_create_augroup("OpenCodeLiveContext", { clear = true }) + + vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { + group = group, + callback = on_cursor_moved, + }) + + vim.api.nvim_create_autocmd("ModeChanged", { + group = group, + callback = on_mode_changed, + }) + + vim.api.nvim_create_autocmd("BufEnter", { + group = group, + callback = on_buf_enter, + }) + + vim.api.nvim_create_autocmd("TextChanged", { + group = group, + callback = on_text_changed, + }) + + send_selection_update() +end + +function M.disable() + if not M.state.enabled then + return + end + + M.state.enabled = false + vim.api.nvim_del_augroup_by_name("OpenCodeLiveContext") + + if M.state.debounce_timer then + M.state.debounce_timer:stop() + M.state.debounce_timer:close() + M.state.debounce_timer = nil + end + + M.state.last_selection = nil +end + +function M.is_enabled() + return M.state.enabled +end + +function M.send_at_mention(file_path, line_start, line_end) + if not server.is_running() then + return false + end + + server.broadcast_at_mentioned(file_path, line_start, line_end) + return true +end + +function M.send_visual_selection_as_mention() + local bufnr = vim.api.nvim_get_current_buf() + local filepath = vim.api.nvim_buf_get_name(bufnr) + + if filepath == "" or not filepath:match("^/") then + return false + end + + local selection = get_visual_selection() + if not selection then + return false + end + + M.send_at_mention(filepath, selection.start_line, selection.end_line) + return true +end + +return M diff --git a/lua/opencode/editor/tcp.lua b/lua/opencode/editor/tcp.lua new file mode 100644 index 00000000..b4607166 --- /dev/null +++ b/lua/opencode/editor/tcp.lua @@ -0,0 +1,57 @@ +local M = {} + +local uv = vim.uv + +function M.create_server(host, port, on_connection) + local server = uv.new_tcp() + if not server then + return nil, "Failed to create TCP server" + end + + local ok, err = server:bind(host, port) + if not ok then + server:close() + return nil, err or "Failed to bind to port" + end + + ok, err = server:listen(128, function(err) + if err then + return + end + + local client = uv.new_tcp() + if not client then + return + end + + server:accept(client) + on_connection(client) + end) + + if not ok then + server:close() + return nil, err or "Failed to listen" + end + + return server +end + +function M.close_server(server) + if server then + server:close() + end +end + +function M.write(client, data) + if client and not client:is_closing() then + client:write(data) + end +end + +function M.close_client(client) + if client and not client:is_closing() then + client:close() + end +end + +return M diff --git a/lua/opencode/editor/utils.lua b/lua/opencode/editor/utils.lua new file mode 100644 index 00000000..ef3f4325 --- /dev/null +++ b/lua/opencode/editor/utils.lua @@ -0,0 +1,157 @@ +local M = {} + +local utf8_char_pattern = "[%z\1-\127\194-\244][\128-\191]*" + +function M.utf8_len(str) + local len = 0 + for _ in str:gmatch(utf8_char_pattern) do + len = len + 1 + end + return len +end + +function M.utf8_sub(str, start_char, end_char) + local result = {} + local char_index = 0 + + for char in str:gmatch(utf8_char_pattern) do + char_index = char_index + 1 + if char_index >= start_char and (not end_char or char_index <= end_char) then + table.insert(result, char) + end + end + + return table.concat(result) +end + +function M.sha1(str) + local h0, h1, h2, h3, h4 = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0 + + local msg = str .. "\128" + local len = #str * 8 + + local zero_bytes = (56 - (#msg % 64)) % 64 + msg = msg .. string.rep("\0", zero_bytes) + + msg = msg .. string.pack(">I8", len) + + local chunk_size = 64 + for i = 1, #msg, chunk_size do + local chunk = string.sub(msg, i, i + chunk_size - 1) + local words = {} + + for j = 1, 16 do + words[j] = string.unpack(">I4", chunk, (j - 1) * 4 + 1) + end + + for j = 17, 80 do + local w = words[j - 3] ~ words[j - 8] ~ words[j - 14] ~ words[j - 16] + words[j] = (w << 1 | w >> 31) & 0xFFFFFFFF + end + + local a, b, c, d, e = h0, h1, h2, h3, h4 + + for j = 1, 80 do + local f, k + if j <= 20 then + f = (b & c) | ((~b) & d) + k = 0x5A827999 + elseif j <= 40 then + f = b ~ c ~ d + k = 0x6ED9EBA1 + elseif j <= 60 then + f = (b & c) | (b & d) | (c & d) + k = 0x8F1BBCDC + else + f = b ~ c ~ d + k = 0xCA62C1D6 + end + + local temp = ((a << 5 | a >> 27) + f + e + k + words[j]) & 0xFFFFFFFF + e = d + d = c + c = (b << 30 | b >> 2) & 0xFFFFFFFF + b = a + a = temp + end + + h0 = (h0 + a) & 0xFFFFFFFF + h1 = (h1 + b) & 0xFFFFFFFF + h2 = (h2 + c) & 0xFFFFFFFF + h3 = (h3 + d) & 0xFFFFFFFF + h4 = (h4 + e) & 0xFFFFFFFF + end + + return string.pack(">I4I4I4I4I4", h0, h1, h2, h3, h4) +end + +function M.base64_encode(str) + local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + local result = {} + local padding = "" + + for i = 1, #str, 3 do + local b1 = string.byte(str, i) + local b2 = string.byte(str, i + 1) or 0 + local b3 = string.byte(str, i + 2) or 0 + + local n = b1 * 65536 + b2 * 256 + b3 + + local c1 = math.floor(n / 262144) % 64 + local c2 = math.floor(n / 4096) % 64 + local c3 = math.floor(n / 64) % 64 + local c4 = n % 64 + + table.insert(result, string.sub(b64chars, c1 + 1, c1 + 1)) + table.insert(result, string.sub(b64chars, c2 + 1, c2 + 1)) + + if i + 1 <= #str then + table.insert(result, string.sub(b64chars, c3 + 1, c3 + 1)) + else + table.insert(result, "=") + end + + if i + 2 <= #str then + table.insert(result, string.sub(b64chars, c4 + 1, c4 + 1)) + else + table.insert(result, "=") + end + end + + return table.concat(result) +end + +function M.parse_http_headers(request) + local headers = {} + local lines = vim.split(request, "\r\n") + + for i, line in ipairs(lines) do + if i > 1 and line ~= "" then + local key, value = line:match("^([^:]+):%s*(.+)$") + if key and value then + headers[key:lower()] = value + end + end + end + + return headers +end + +function M.constant_time_compare(a, b) + if type(a) ~= "string" or type(b) ~= "string" then + return false + end + + if #a ~= #b then + return false + end + + local result = 0 + for i = 1, #a do + result = result | (string.byte(a, i) ~ string.byte(b, i)) + end + + return result == 0 +end + +return M diff --git a/plugin/live-context.lua b/plugin/live-context.lua new file mode 100644 index 00000000..0fecdf0a --- /dev/null +++ b/plugin/live-context.lua @@ -0,0 +1,17 @@ +vim.api.nvim_create_user_command("OpenCodeLiveContextStart", function() + local ok, result = require("opencode").start_live_context() + if ok then + vim.notify("OpenCode live context started on port " .. result, vim.log.levels.INFO, { title = "OpenCode" }) + else + vim.notify("Failed to start live context: " .. result, vim.log.levels.ERROR, { title = "OpenCode" }) + end +end, { desc = "Start OpenCode live context WebSocket server" }) + +vim.api.nvim_create_user_command("OpenCodeLiveContextStop", function() + require("opencode").stop_live_context() + vim.notify("OpenCode live context stopped", vim.log.levels.INFO, { title = "OpenCode" }) +end, { desc = "Stop OpenCode live context WebSocket server" }) + +vim.api.nvim_create_user_command("OpenCodeAttach", function() + require("opencode").attach_context() +end, { range = true, desc = "Attach visual selection to OpenCode context (no submit)" }) From 99e6d63b69ecfa907af7d21e96cc4278b1dadceb Mon Sep 17 00:00:00 2001 From: itsmeyaw Date: Fri, 7 Aug 2026 18:09:43 +0200 Subject: [PATCH 2/9] fix(live-context): stabilize OpenCode TUI integration --- README.md | 40 +++++------ lua/opencode.lua | 33 +++++++-- lua/opencode/config.lua | 23 ------- lua/opencode/editor/client.lua | 11 +-- lua/opencode/editor/frame.lua | 39 ++++------- lua/opencode/editor/init.lua | 11 ++- lua/opencode/editor/lockfile.lua | 41 ++++++++--- lua/opencode/editor/selection.lua | 109 +++++++++++++++++++----------- lua/opencode/editor/utils.lua | 71 +++++++++++++------ plugin/live-context.lua | 32 +++++++-- 10 files changed, 257 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index 146588fa..2ed00b2d 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ vim.keymap.set({ "n" }, "", 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 @@ -299,38 +299,38 @@ Wraps Prompt as an operator, supporting ranges and dot-repeat. ### Live Context — `require("opencode").start_live_context()` -Enable real-time broadcasting of file/selection context to OpenCode TUI via WebSocket. +Keep OpenCode aware of the file and visual selection you are working in. -The OpenCode TUI automatically discovers and connects to: -1. Lockfile at `~/.claude/ide/[port].lock` -2. Environment variable `OPENCODE_EDITOR_SSE_PORT` or `CLAUDE_CODE_SSE_PORT` - -**Setup:** +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, -- Auto-start on VimEnter - port = 0, -- 0 for random port - auth_token = nil -- Optional: set to string or true to generate - } + 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: +-- Or start manually with the configured options. vim.keymap.set("n", "ol", function() require("opencode").start_live_context() end, { desc = "Start live context" }) + +vim.keymap.set("x", "oa", function() + require("opencode").attach_context() +end, { desc = "Attach selection to OpenCode" }) ``` -**Usage:** -- OpenCode TUI will show current file selection in real-time -- Use `attach_context()` to send visual selection without submitting prompt -- Selection updates broadcast automatically when you move cursor or change files +`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 | -**API:** -- `start_live_context(opts)` - Start WebSocket server and selection tracking -- `stop_live_context()` - Stop server and cleanup -- `attach_context()` - Send current selection to OpenCode (no submit) +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()` diff --git a/lua/opencode.lua b/lua/opencode.lua index 833d282c..16e92053 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -117,10 +117,26 @@ M.editor = { } function M.start_live_context(opts) - opts = opts or {} + 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 @@ -150,12 +166,21 @@ function M.stop_live_context() end end -function M.attach_context() +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 selection.send_visual_selection_as_mention() then - vim.notify("No selection to attach", vim.log.levels.WARN, { title = "OpenCode" }) + 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 diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 79158b29..29167e72 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -140,27 +140,4 @@ if M.opts.events.reload.enabled then end end -if M.opts.live_context and M.opts.live_context.enabled then - vim.api.nvim_create_autocmd("VimEnter", { - group = vim.api.nvim_create_augroup("OpenCodeLiveContextAutoStart", { clear = true }), - callback = function() - local opencode = require("opencode") - local ok, result = opencode.start_live_context(M.opts.live_context) - if ok then - vim.notify( - "OpenCode live context started on port " .. result, - vim.log.levels.INFO, - { title = "OpenCode" } - ) - else - vim.notify( - "Failed to start OpenCode live context: " .. result, - vim.log.levels.ERROR, - { title = "OpenCode" } - ) - end - end, - }) -end - return M diff --git a/lua/opencode/editor/client.lua b/lua/opencode/editor/client.lua index c461fa4e..d788a6be 100644 --- a/lua/opencode/editor/client.lua +++ b/lua/opencode/editor/client.lua @@ -1,5 +1,3 @@ -local M = {} -local uv = vim.uv local frame_module = require("opencode.editor.frame") local Client = {} @@ -50,7 +48,7 @@ function Client:handle_data(data, on_message, on_close) local request = self.buffer:sub(1, headers_end + 3) self.buffer = self.buffer:sub(headers_end + 4) - local ok, result = pcall(on_message, "handshake", request) + local ok = pcall(on_message, "handshake", request) if not ok then self:close() on_close() @@ -72,8 +70,11 @@ function Client:handle_data(data, on_message, on_close) self:send(frame_module.pong_frame()) elseif decoded.opcode == 0xA then elseif decoded.opcode == 0x1 then - local ok, err = pcall(on_message, "message", decoded.payload) + local ok = pcall(on_message, "message", decoded.payload) if not ok then + self:close() + on_close() + return end end else @@ -90,4 +91,4 @@ function Client:is_connected() return self.state == "connected" and self.client and not self.client:is_closing() end -return M +return Client diff --git a/lua/opencode/editor/frame.lua b/lua/opencode/editor/frame.lua index 4b16eee4..5f2f77a2 100644 --- a/lua/opencode/editor/frame.lua +++ b/lua/opencode/editor/frame.lua @@ -1,11 +1,9 @@ local M = {} local utils = require("opencode.editor.utils") +local bit = require("bit") -local OPCODE_CONTINUATION = 0x0 local OPCODE_TEXT = 0x1 -local OPCODE_BINARY = 0x2 local OPCODE_CLOSE = 0x8 -local OPCODE_PING = 0x9 local OPCODE_PONG = 0xA function M.decode_frame(data) @@ -16,14 +14,11 @@ function M.decode_frame(data) local byte1 = string.byte(data, 1) local byte2 = string.byte(data, 2) - local fin = (byte1 & 0x80) ~= 0 - local rsv1 = (byte1 & 0x40) ~= 0 - local rsv2 = (byte1 & 0x20) ~= 0 - local rsv3 = (byte1 & 0x10) ~= 0 - local opcode = byte1 & 0x0F + local fin = bit.band(byte1, 0x80) ~= 0 + local opcode = bit.band(byte1, 0x0F) - local masked = (byte2 & 0x80) ~= 0 - local payload_len = byte2 & 0x7F + local masked = bit.band(byte2, 0x80) ~= 0 + local payload_len = bit.band(byte2, 0x7F) local offset = 2 @@ -31,13 +26,13 @@ function M.decode_frame(data) if #data < offset + 2 then return nil end - payload_len = string.unpack(">I2", data, offset + 1) + payload_len = utils.unpack_u16(data, offset + 1) offset = offset + 2 elseif payload_len == 127 then if #data < offset + 8 then return nil end - payload_len = string.unpack(">I8", data, offset + 1) + payload_len = utils.unpack_u64(data, offset + 1) offset = offset + 8 end @@ -61,7 +56,7 @@ function M.decode_frame(data) for i = 1, #payload do local byte = string.byte(payload, i) local mask_byte = string.byte(mask_key, ((i - 1) % 4) + 1) - table.insert(decoded, string.char(byte ~ mask_byte)) + table.insert(decoded, string.char(bit.bxor(byte, mask_byte))) end payload = table.concat(decoded) end @@ -81,19 +76,19 @@ function M.encode_frame(payload, opcode, masked) local len = #payload local frame = {} - local byte1 = 0x80 | opcode + local byte1 = bit.bor(0x80, opcode) table.insert(frame, string.char(byte1)) local byte2 = masked and 0x80 or 0x00 if len <= 125 then - table.insert(frame, string.char(byte2 | len)) + table.insert(frame, string.char(bit.bor(byte2, len))) elseif len <= 65535 then - table.insert(frame, string.char(byte2 | 126)) - table.insert(frame, string.pack(">I2", len)) + table.insert(frame, string.char(bit.bor(byte2, 126))) + table.insert(frame, utils.pack_u16(len)) else - table.insert(frame, string.char(byte2 | 127)) - table.insert(frame, string.pack(">I8", len)) + table.insert(frame, string.char(bit.bor(byte2, 127))) + table.insert(frame, utils.pack_u64(len)) end if masked then @@ -108,7 +103,7 @@ function M.encode_frame(payload, opcode, masked) for i = 1, len do local byte = string.byte(payload, i) local mask_byte = string.byte(mask_str, ((i - 1) % 4) + 1) - table.insert(masked_payload, string.char(byte ~ mask_byte)) + table.insert(masked_payload, string.char(bit.bxor(byte, mask_byte))) end table.insert(frame, table.concat(masked_payload)) else @@ -122,10 +117,6 @@ function M.close_frame() return M.encode_frame("", OPCODE_CLOSE, false) end -function M.ping_frame() - return M.encode_frame("", OPCODE_PING, false) -end - function M.pong_frame() return M.encode_frame("", OPCODE_PONG, false) end diff --git a/lua/opencode/editor/init.lua b/lua/opencode/editor/init.lua index 06b51303..164951b3 100644 --- a/lua/opencode/editor/init.lua +++ b/lua/opencode/editor/init.lua @@ -26,11 +26,14 @@ local function broadcast(method, params) params = params or {}, } + local sent = false for _, client in ipairs(M.state.clients) do if client:is_connected() then client:send_json(message) + sent = true end end + return sent end function M.start(port, auth_token) @@ -79,6 +82,10 @@ function M.start(port, auth_token) }, } client:send_json(response) + elseif message.method == "notifications/initialized" then + vim.schedule(function() + require("opencode.editor.selection").update(true) + end) end end end @@ -141,7 +148,7 @@ function M.get_auth_token() end function M.broadcast_selection_changed(file_path, selection) - broadcast("selection_changed", { + return broadcast("selection_changed", { text = selection.text or "", filePath = file_path, fileUrl = "file://" .. file_path, @@ -160,7 +167,7 @@ function M.broadcast_selection_changed(file_path, selection) end function M.broadcast_at_mentioned(file_path, line_start, line_end) - broadcast("at_mentioned", { + return broadcast("at_mentioned", { filePath = file_path, lineStart = line_start, lineEnd = line_end, diff --git a/lua/opencode/editor/lockfile.lua b/lua/opencode/editor/lockfile.lua index e5f36ccb..bb8019de 100644 --- a/lua/opencode/editor/lockfile.lua +++ b/lua/opencode/editor/lockfile.lua @@ -1,12 +1,11 @@ local M = {} -local function get_lockfile_path(port) - local home = vim.fn.expand("~") - local lockfile_dir = home .. "/.claude/ide" - - vim.fn.mkdir(lockfile_dir, "p") +local function get_lockfile_dir() + return vim.fn.expand("~/.claude/ide") +end - return lockfile_dir .. "/" .. port .. ".lock" +local function get_lockfile_path(port) + return get_lockfile_dir() .. "/" .. port .. ".lock" end local function get_workspace_folders() @@ -18,11 +17,32 @@ local function get_workspace_folders() 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", tonumber("700", 8)) == 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 = { @@ -38,8 +58,8 @@ function M.create(port, auth_token) local json = vim.json.encode(lock_content) - local temp_file = lockfile_path .. ".tmp" - local fd = io.open(temp_file, "w") + 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 @@ -108,8 +128,7 @@ function M.read(port) end function M.clean_all() - local home = vim.fn.expand("~") - local lockfile_dir = home .. "/.claude/ide" + local lockfile_dir = get_lockfile_dir() if vim.fn.isdirectory(lockfile_dir) == 0 then return @@ -122,7 +141,7 @@ function M.clean_all() if lock_data and lock_data.pid then local pid = lock_data.pid - if not vim.loop.kill(pid, 0) then + if not vim.uv.kill(pid, 0) then os.remove(file) end end diff --git a/lua/opencode/editor/selection.lua b/lua/opencode/editor/selection.lua index 759e6cc5..95f0767f 100644 --- a/lua/opencode/editor/selection.lua +++ b/lua/opencode/editor/selection.lua @@ -1,5 +1,5 @@ local M = {} -local server = require("opencode.editor.server") +local server = require("opencode.editor") M.state = { last_selection = nil, @@ -7,47 +7,59 @@ M.state = { debounce_timer = nil, } +local function is_visual_mode(mode) + return mode == "v" or mode == "V" or mode == "\22" +end + local function get_visual_selection() local bufnr = vim.api.nvim_get_current_buf() - local mode = vim.fn.mode() + local mode = vim.api.nvim_get_mode().mode - if not mode:match("[vV\22]") then + if not is_visual_mode(mode) then return nil end - local start_pos = vim.api.nvim_buf_get_mark(bufnr, "<") - local end_pos = vim.api.nvim_buf_get_mark(bufnr, ">") + local anchor = vim.fn.getpos("v") + local cursor = vim.api.nvim_win_get_cursor(0) - if not start_pos or not end_pos then + if anchor[2] == 0 then return nil end + local start_pos = { anchor[2], anchor[3] } + local end_pos = { cursor[1], cursor[2] + 1 } if start_pos[1] > end_pos[1] or (start_pos[1] == end_pos[1] and start_pos[2] > end_pos[2]) then start_pos, end_pos = end_pos, start_pos end local start_line = start_pos[1] - local start_col = start_pos[2] + 1 + local start_col = start_pos[2] local end_line = end_pos[1] - local end_col = end_pos[2] + 1 + local end_col = end_pos[2] - local text = "" local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) - if #lines > 0 then - if start_line == end_line then - text = string.sub(lines[1] or "", start_col, end_col) - else - lines[1] = string.sub(lines[1] or "", start_col) - lines[#lines] = string.sub(lines[#lines] or "", 1, end_col) - text = table.concat(lines, "\n") - end + if #lines == 0 then + return nil + end + + local text + if mode == "V" then + start_col = 1 + end_col = #(lines[#lines] or "") + text = table.concat(lines, "\n") + elseif start_line == end_line then + text = string.sub(lines[1] or "", start_col, end_col) + else + lines[1] = string.sub(lines[1] or "", start_col) + lines[#lines] = string.sub(lines[#lines] or "", 1, end_col) + text = table.concat(lines, "\n") end return { start_line = start_line - 1, start_col = start_col - 1, end_line = end_line - 1, - end_col = end_col - 1, + end_col = end_col, text = text, is_empty = #text == 0, } @@ -59,15 +71,12 @@ local function get_cursor_position() local line = cursor[1] local col = cursor[2] - local line_text = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or "" - local char = string.sub(line_text, col + 1, col + 1) - return { start_line = line - 1, start_col = col, end_line = line - 1, end_col = col, - text = char, + text = "", is_empty = true, } end @@ -80,10 +89,10 @@ local function get_current_selection() return nil end - local mode = vim.fn.mode() + local mode = vim.api.nvim_get_mode().mode local selection - if mode:match("[vV\22]") then + if is_visual_mode(mode) then selection = get_visual_selection() else selection = get_cursor_position() @@ -104,12 +113,13 @@ local function selection_key(selection) return "" end return string.format( - "%s:%d:%d-%d:%d", + "%s:%d:%d-%d:%d:%s", selection.file_path, selection.selection.start_line, selection.selection.start_col, selection.selection.end_line, - selection.selection.end_col + selection.selection.end_col, + selection.selection.text ) end @@ -124,14 +134,14 @@ local function has_selection_changed(current) return last_key ~= current_key end -local function send_selection_update() +function M.update(force) if not server.is_running() then return end local current = get_current_selection() - if current and has_selection_changed(current) then + if current and (force or has_selection_changed(current)) then server.broadcast_selection_changed(current.file_path, current.selection) M.state.last_selection = current end @@ -144,12 +154,18 @@ local function debounce_send_selection() M.state.debounce_timer = nil end - M.state.debounce_timer = vim.uv.new_timer() - M.state.debounce_timer:start(100, 0, vim.schedule_wrap(function() - send_selection_update() - if M.state.debounce_timer then - M.state.debounce_timer:close() + local timer = vim.uv.new_timer() + if not timer then + M.update() + return + end + + M.state.debounce_timer = timer + timer:start(100, 0, vim.schedule_wrap(function() + if M.state.debounce_timer == timer then M.state.debounce_timer = nil + timer:close() + M.update() end end)) end @@ -211,7 +227,7 @@ function M.enable() callback = on_text_changed, }) - send_selection_update() + M.update() end function M.disable() @@ -237,11 +253,14 @@ end function M.send_at_mention(file_path, line_start, line_end) if not server.is_running() then - return false + return false, "Live context is not running" end - server.broadcast_at_mentioned(file_path, line_start, line_end) - return true + if not server.broadcast_at_mentioned(file_path, line_start, line_end) then + return false, "OpenCode TUI is not connected" + end + + return true, nil end function M.send_visual_selection_as_mention() @@ -249,16 +268,24 @@ function M.send_visual_selection_as_mention() local filepath = vim.api.nvim_buf_get_name(bufnr) if filepath == "" or not filepath:match("^/") then - return false + return false, "Current buffer is not a file" end local selection = get_visual_selection() if not selection then - return false + return false, "No visual selection to attach" + end + + return M.send_at_mention(filepath, selection.start_line, selection.end_line) +end + +function M.send_range_as_mention(line_start, line_end) + local filepath = vim.api.nvim_buf_get_name(0) + if filepath == "" or not filepath:match("^/") then + return false, "Current buffer is not a file" end - M.send_at_mention(filepath, selection.start_line, selection.end_line) - return true + return M.send_at_mention(filepath, line_start - 1, line_end - 1) end return M diff --git a/lua/opencode/editor/utils.lua b/lua/opencode/editor/utils.lua index ef3f4325..e182757c 100644 --- a/lua/opencode/editor/utils.lua +++ b/lua/opencode/editor/utils.lua @@ -1,7 +1,40 @@ +local bit = require("bit") + local M = {} local utf8_char_pattern = "[%z\1-\127\194-\244][\128-\191]*" +function M.pack_u16(value) + return string.char(bit.band(bit.rshift(value, 8), 0xFF), bit.band(value, 0xFF)) +end + +function M.pack_u32(value) + return string.char( + bit.band(bit.rshift(value, 24), 0xFF), + bit.band(bit.rshift(value, 16), 0xFF), + bit.band(bit.rshift(value, 8), 0xFF), + bit.band(value, 0xFF) + ) +end + +function M.pack_u64(value) + local high = math.floor(value / 0x100000000) + local low = value % 0x100000000 + return M.pack_u32(high) .. M.pack_u32(low) +end + +function M.unpack_u16(value, offset) + local high, low = value:byte(offset, offset + 1) + return high * 0x100 + low +end + +function M.unpack_u64(value, offset) + local b1, b2, b3, b4, b5, b6, b7, b8 = value:byte(offset, offset + 7) + local high = ((b1 * 0x100 + b2) * 0x100 + b3) * 0x100 + b4 + local low = ((b5 * 0x100 + b6) * 0x100 + b7) * 0x100 + b8 + return high * 0x100000000 + low +end + function M.utf8_len(str) local len = 0 for _ in str:gmatch(utf8_char_pattern) do @@ -33,7 +66,7 @@ function M.sha1(str) local zero_bytes = (56 - (#msg % 64)) % 64 msg = msg .. string.rep("\0", zero_bytes) - msg = msg .. string.pack(">I8", len) + msg = msg .. M.pack_u64(len) local chunk_size = 64 for i = 1, #msg, chunk_size do @@ -41,12 +74,14 @@ function M.sha1(str) local words = {} for j = 1, 16 do - words[j] = string.unpack(">I4", chunk, (j - 1) * 4 + 1) + local offset = (j - 1) * 4 + 1 + local b1, b2, b3, b4 = chunk:byte(offset, offset + 3) + words[j] = ((b1 * 0x100 + b2) * 0x100 + b3) * 0x100 + b4 end for j = 17, 80 do - local w = words[j - 3] ~ words[j - 8] ~ words[j - 14] ~ words[j - 16] - words[j] = (w << 1 | w >> 31) & 0xFFFFFFFF + local w = bit.bxor(words[j - 3], words[j - 8], words[j - 14], words[j - 16]) + words[j] = bit.rol(w, 1) end local a, b, c, d, e = h0, h1, h2, h3, h4 @@ -54,42 +89,40 @@ function M.sha1(str) for j = 1, 80 do local f, k if j <= 20 then - f = (b & c) | ((~b) & d) + f = bit.bor(bit.band(b, c), bit.band(bit.bnot(b), d)) k = 0x5A827999 elseif j <= 40 then - f = b ~ c ~ d + f = bit.bxor(b, c, d) k = 0x6ED9EBA1 elseif j <= 60 then - f = (b & c) | (b & d) | (c & d) + f = bit.bor(bit.band(b, c), bit.band(b, d), bit.band(c, d)) k = 0x8F1BBCDC else - f = b ~ c ~ d + f = bit.bxor(b, c, d) k = 0xCA62C1D6 end - local temp = ((a << 5 | a >> 27) + f + e + k + words[j]) & 0xFFFFFFFF + local temp = bit.band(bit.rol(a, 5) + f + e + k + words[j], 0xFFFFFFFF) e = d d = c - c = (b << 30 | b >> 2) & 0xFFFFFFFF + c = bit.rol(b, 30) b = a a = temp end - h0 = (h0 + a) & 0xFFFFFFFF - h1 = (h1 + b) & 0xFFFFFFFF - h2 = (h2 + c) & 0xFFFFFFFF - h3 = (h3 + d) & 0xFFFFFFFF - h4 = (h4 + e) & 0xFFFFFFFF + h0 = bit.band(h0 + a, 0xFFFFFFFF) + h1 = bit.band(h1 + b, 0xFFFFFFFF) + h2 = bit.band(h2 + c, 0xFFFFFFFF) + h3 = bit.band(h3 + d, 0xFFFFFFFF) + h4 = bit.band(h4 + e, 0xFFFFFFFF) end - return string.pack(">I4I4I4I4I4", h0, h1, h2, h3, h4) + return M.pack_u32(h0) .. M.pack_u32(h1) .. M.pack_u32(h2) .. M.pack_u32(h3) .. M.pack_u32(h4) end function M.base64_encode(str) local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local result = {} - local padding = "" - for i = 1, #str, 3 do local b1 = string.byte(str, i) local b2 = string.byte(str, i + 1) or 0 @@ -148,7 +181,7 @@ function M.constant_time_compare(a, b) local result = 0 for i = 1, #a do - result = result | (string.byte(a, i) ~ string.byte(b, i)) + result = bit.bor(result, bit.bxor(string.byte(a, i), string.byte(b, i))) end return result == 0 diff --git a/plugin/live-context.lua b/plugin/live-context.lua index 0fecdf0a..d49ad80e 100644 --- a/plugin/live-context.lua +++ b/plugin/live-context.lua @@ -1,10 +1,16 @@ -vim.api.nvim_create_user_command("OpenCodeLiveContextStart", function() - local ok, result = require("opencode").start_live_context() +local live_context = vim.g.opencode_opts and vim.g.opencode_opts.live_context + +local function start(opts) + local ok, result = require("opencode").start_live_context(opts) if ok then vim.notify("OpenCode live context started on port " .. result, vim.log.levels.INFO, { title = "OpenCode" }) else vim.notify("Failed to start live context: " .. result, vim.log.levels.ERROR, { title = "OpenCode" }) end +end + +vim.api.nvim_create_user_command("OpenCodeLiveContextStart", function() + start() end, { desc = "Start OpenCode live context WebSocket server" }) vim.api.nvim_create_user_command("OpenCodeLiveContextStop", function() @@ -12,6 +18,24 @@ vim.api.nvim_create_user_command("OpenCodeLiveContextStop", function() vim.notify("OpenCode live context stopped", vim.log.levels.INFO, { title = "OpenCode" }) end, { desc = "Stop OpenCode live context WebSocket server" }) -vim.api.nvim_create_user_command("OpenCodeAttach", function() - require("opencode").attach_context() +vim.api.nvim_create_user_command("OpenCodeAttach", function(opts) + require("opencode").attach_context(opts.range > 0 and opts.line1 or nil, opts.range > 0 and opts.line2 or nil) end, { range = true, desc = "Attach visual selection to OpenCode context (no submit)" }) + +if live_context and live_context.enabled then + vim.schedule(function() + if not require("opencode.editor").is_running() then + start(live_context) + end + end) +end + +vim.api.nvim_create_autocmd("VimLeavePre", { + group = vim.api.nvim_create_augroup("OpenCodeLiveContextShutdown", { clear = true }), + callback = function() + if require("opencode.editor").is_running() then + require("opencode").stop_live_context() + end + end, + desc = "Stop OpenCode live context and remove its lockfile", +}) From ce100464953e556f3383a8a6de25bee0991a53c9 Mon Sep 17 00:00:00 2001 From: itsmeyaw Date: Fri, 7 Aug 2026 18:27:45 +0200 Subject: [PATCH 3/9] refactor(live-context): isolate websocket server --- LIVE_CONTEXT.md | 189 ------------------ lua/opencode.lua | 2 +- lua/opencode/editor/init.lua | 178 +---------------- lua/opencode/editor/selection.lua | 2 +- .../{editor => server/websocket}/client.lua | 12 +- .../{editor => server/websocket}/frame.lua | 10 +- .../websocket}/handshake.lua | 13 +- lua/opencode/server/websocket/init.lua | 176 ++++++++++++++++ .../{editor => server/websocket}/tcp.lua | 8 +- .../{editor => server/websocket}/utils.lua | 30 +-- plugin/live-context.lua | 4 +- 11 files changed, 206 insertions(+), 418 deletions(-) delete mode 100644 LIVE_CONTEXT.md rename lua/opencode/{editor => server/websocket}/client.lua (85%) rename lua/opencode/{editor => server/websocket}/frame.lua (95%) rename lua/opencode/{editor => server/websocket}/handshake.lua (82%) create mode 100644 lua/opencode/server/websocket/init.lua rename lua/opencode/{editor => server/websocket}/tcp.lua (92%) rename lua/opencode/{editor => server/websocket}/utils.lua (87%) diff --git a/LIVE_CONTEXT.md b/LIVE_CONTEXT.md deleted file mode 100644 index 9196530b..00000000 --- a/LIVE_CONTEXT.md +++ /dev/null @@ -1,189 +0,0 @@ -# OpenCode.nvim Live Context Implementation - -## Overview - -This implementation adds real-time WebSocket-based live context broadcasting to opencode.nvim, enabling OpenCode TUI to track the current file/selection in Neovim. - -## Architecture - -### Components - -1. **WebSocket Server** (`lua/opencode/editor/`) - - `init.lua` - Main server module with start/stop/broadcast functions - - `tcp.lua` - TCP server using `vim.uv` - - `frame.lua` - WebSocket frame encoder/decoder (RFC 6455) - - `handshake.lua` - WebSocket upgrade handshake with auth - - `client.lua` - Client connection management - - `utils.lua` - Pure Lua utilities (SHA-1, base64, UTF-8) - -2. **Selection Tracking** (`lua/opencode/editor/selection.lua`) - - Autocommands for CursorMoved, ModeChanged, BufEnter, TextChanged - - Debounced updates (100ms) - - Visual selection and cursor position capture - -3. **Lockfile Manager** (`lua/opencode/editor/lockfile.lua`) - - Creates `~/.claude/ide/[port].lock` - - Compatible with Claude Code lockfile format - - Atomic file creation - - Auto-cleanup of stale lockfiles - -### Protocol - -**JSON-RPC 2.0 Messages:** - -```json -// Selection Changed (Neovim -> OpenCode) -{ - "jsonrpc": "2.0", - "method": "selection_changed", - "params": { - "text": "selected text", - "filePath": "/path/to/file", - "fileUrl": "file:///path/to/file", - "selection": { - "start": { "line": 10, "character": 5 }, - "end": { "line": 15, "character": 20 }, - "isEmpty": false - } - } -} - -// At-Mentioned (Neovim -> OpenCode) -{ - "jsonrpc": "2.0", - "method": "at_mentioned", - "params": { - "filePath": "/path/to/file", - "lineStart": 10, - "lineEnd": 20 - } -} -``` - -## Usage - -### Configuration - -```lua -vim.g.opencode_opts = { - live_context = { - enabled = true, -- Auto-start on VimEnter - port = 0, -- 0 for random port - auth_token = nil -- Optional: set to string or true to generate - } -} -``` - -### Commands - -- `:OpenCodeLiveContextStart` - Start WebSocket server -- `:OpenCodeLiveContextStop` - Stop WebSocket server -- `:'<,'>OpenCodeAttach` - Attach visual selection (no submit) - -### API - -```lua -local opencode = require("opencode") - --- Start live context -opencode.start_live_context({ port = 0, auth_token = nil }) - --- Stop live context -opencode.stop_live_context() - --- Attach current selection without submitting -opencode.attach_context() -``` - -### Keymaps (Recommended) - -```lua -vim.keymap.set("n", "ol", function() - require("opencode").start_live_context() -end, { desc = "Start OpenCode live context" }) - -vim.keymap.set("x", "oa", function() - require("opencode").attach_context() -end, { desc = "Attach selection to OpenCode" }) -``` - -## OpenCode TUI Integration - -OpenCode TUI automatically discovers the WebSocket server via: - -1. **Lockfile** at `~/.claude/ide/[port].lock` - ```json - { - "pid": 12345, - "workspaceFolders": ["/path/to/project"], - "ideName": "Neovim", - "transport": "ws", - "authToken": "optional-token" - } - ``` - -2. **Environment Variable**: `OPENCODE_EDITOR_SSE_PORT` or `CLAUDE_CODE_SSE_PORT` - -## Implementation Notes - -### No External Dependencies - -All WebSocket code is pure Lua using Neovim's `vim.uv` (libuv) - no external Lua packages required. - -### Compatibility - -- Compatible with Claude Code's WebSocket protocol -- Uses same lockfile location and format -- Can share environment variables for discovery - -### Performance - -- Debounced updates (100ms) prevent excessive broadcasts -- Lightweight frame encoding without heavy abstraction -- Connection cleanup on Neovim exit - -### Future Enhancements - -1. **MCP Tools**: Expose tools that OpenCode can invoke - - `openFile` - Open file in Neovim - - `getCurrentSelection` - Get current selection - - `openDiff` - Show diff view - -2. **Bidirectional Communication** - - Handle tool calls from OpenCode - - Progress notifications for long operations - -3. **Multi-client Support** - - Connect multiple OpenCode TUI instances - - Per-client selection state - -## Files Created - -``` -lua/opencode/editor/ -├── init.lua # WebSocket server -├── tcp.lua # TCP server -├── frame.lua # WebSocket frames -├── handshake.lua # WebSocket handshake -├── client.lua # Client management -├── utils.lua # Utilities -├── selection.lua # Selection tracking -└── lockfile.lua # Lockfile manager - -plugin/ -└── live-context.lua # User commands -``` - -## Testing - -```bash -# Start OpenCode TUI -opencode - -# In Neovim (another terminal) -nvim -:OpenCodeLiveContextStart - -# OpenCode TUI should show "Connected to editor: Neovim" -# Move cursor in Neovim - TUI shows current file/line -``` diff --git a/lua/opencode.lua b/lua/opencode.lua index 16e92053..8fc18c4f 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -111,7 +111,7 @@ end M.format = require("opencode.context").format M.editor = { - server = require("opencode.editor"), + server = require("opencode.server.websocket"), selection = require("opencode.editor.selection"), lockfile = require("opencode.editor.lockfile"), } diff --git a/lua/opencode/editor/init.lua b/lua/opencode/editor/init.lua index 164951b3..d507c490 100644 --- a/lua/opencode/editor/init.lua +++ b/lua/opencode/editor/init.lua @@ -1,177 +1 @@ -local M = {} -local Client = require("opencode.editor.client") -local handshake = require("opencode.editor.handshake") -local tcp_server = require("opencode.editor.tcp") - -M.state = { - server = nil, - clients = {}, - port = nil, - auth_token = nil, -} - -local function remove_client(client) - for i, c in ipairs(M.state.clients) do - if c == client then - table.remove(M.state.clients, i) - break - end - end -end - -local function broadcast(method, params) - local message = { - jsonrpc = "2.0", - method = method, - params = params or {}, - } - - local sent = false - for _, client in ipairs(M.state.clients) do - if client:is_connected() then - client:send_json(message) - sent = true - end - end - return sent -end - -function M.start(port, auth_token) - if M.state.server then - return false, "Server already running on port " .. M.state.port - end - - port = port or 0 - auth_token = auth_token or nil - - local server, err = tcp_server.create_server("127.0.0.1", port, function(tcp_client) - local client = Client.new(tcp_client) - client.buffer = "" - - local function handle_message(type, data) - if type == "handshake" then - local ok, headers = handshake.validate_upgrade_request(data, auth_token) - if not ok then - local response = "HTTP/1.1 400 Bad Request\r\n\r\n" .. (headers or "Invalid handshake") - client:send(response) - client:close() - remove_client(client) - return - end - - local ws_key = headers["sec-websocket-key"] - local response = handshake.create_response(ws_key, auth_token) - client:send(response) - client:set_state("connected") - client.authenticated = auth_token and true or false - - table.insert(M.state.clients, client) - elseif type == "message" then - local ok, message = pcall(vim.json.decode, data) - if ok and message then - if message.method == "initialize" then - local response = { - jsonrpc = "2.0", - id = message.id, - result = { - protocolVersion = "2025-11-25", - serverInfo = { - name = "opencode.nvim", - version = "1.0.0", - }, - }, - } - client:send_json(response) - elseif message.method == "notifications/initialized" then - vim.schedule(function() - require("opencode.editor.selection").update(true) - end) - end - end - end - end - - local function handle_close() - remove_client(client) - end - - tcp_client:read_start(function(err, data) - if err or not data then - client:close() - remove_client(client) - return - end - - client:handle_data(data, handle_message, handle_close) - end) - end) - - if not server then - return false, err or "Failed to create server" - end - - local sockname = server:getsockname() - local actual_port = sockname.port - - M.state.server = server - M.state.port = actual_port - M.state.auth_token = auth_token - - return true, actual_port -end - -function M.stop() - for _, client in ipairs(M.state.clients) do - client:close() - end - M.state.clients = {} - - if M.state.server then - M.state.server:close() - M.state.server = nil - end - - M.state.port = nil - M.state.auth_token = nil -end - -function M.is_running() - return M.state.server ~= nil -end - -function M.get_port() - return M.state.port -end - -function M.get_auth_token() - return M.state.auth_token -end - -function M.broadcast_selection_changed(file_path, selection) - return broadcast("selection_changed", { - text = selection.text or "", - filePath = file_path, - fileUrl = "file://" .. file_path, - selection = { - start = { - line = selection.start_line, - character = selection.start_col, - }, - ["end"] = { - line = selection.end_line, - character = selection.end_col, - }, - isEmpty = selection.is_empty or false, - }, - }) -end - -function M.broadcast_at_mentioned(file_path, line_start, line_end) - return broadcast("at_mentioned", { - filePath = file_path, - lineStart = line_start, - lineEnd = line_end, - }) -end - -return M +return require("opencode.server.websocket") diff --git a/lua/opencode/editor/selection.lua b/lua/opencode/editor/selection.lua index 95f0767f..8c54ff62 100644 --- a/lua/opencode/editor/selection.lua +++ b/lua/opencode/editor/selection.lua @@ -1,5 +1,5 @@ local M = {} -local server = require("opencode.editor") +local server = require("opencode.server.websocket") M.state = { last_selection = nil, diff --git a/lua/opencode/editor/client.lua b/lua/opencode/server/websocket/client.lua similarity index 85% rename from lua/opencode/editor/client.lua rename to lua/opencode/server/websocket/client.lua index d788a6be..709a6ab9 100644 --- a/lua/opencode/editor/client.lua +++ b/lua/opencode/server/websocket/client.lua @@ -1,4 +1,4 @@ -local frame_module = require("opencode.editor.frame") +local frame = require("opencode.server.websocket.frame") local Client = {} Client.__index = Client @@ -19,15 +19,13 @@ function Client:send(data) end function Client:send_json(message) - local json = vim.json.encode(message) - local ws_frame = frame_module.text_frame(json) - self:send(ws_frame) + self:send(frame.text_frame(vim.json.encode(message))) end function Client:close() if self.client and not self.client:is_closing() then if self.state == "connected" then - self:send(frame_module.close_frame()) + self:send(frame.close_frame()) end self.client:close() end @@ -55,7 +53,7 @@ function Client:handle_data(data, on_message, on_close) return end elseif self.state == "connected" then - local decoded = frame_module.decode_frame(self.buffer) + local decoded = frame.decode_frame(self.buffer) if not decoded then break end @@ -67,7 +65,7 @@ function Client:handle_data(data, on_message, on_close) on_close() return elseif decoded.opcode == 0x9 then - self:send(frame_module.pong_frame()) + self:send(frame.pong_frame()) elseif decoded.opcode == 0xA then elseif decoded.opcode == 0x1 then local ok = pcall(on_message, "message", decoded.payload) diff --git a/lua/opencode/editor/frame.lua b/lua/opencode/server/websocket/frame.lua similarity index 95% rename from lua/opencode/editor/frame.lua rename to lua/opencode/server/websocket/frame.lua index 5f2f77a2..d3ecc8ce 100644 --- a/lua/opencode/editor/frame.lua +++ b/lua/opencode/server/websocket/frame.lua @@ -1,6 +1,7 @@ -local M = {} -local utils = require("opencode.editor.utils") local bit = require("bit") +local utils = require("opencode.server.websocket.utils") + +local M = {} local OPCODE_TEXT = 0x1 local OPCODE_CLOSE = 0x8 @@ -36,7 +37,7 @@ function M.decode_frame(data) offset = offset + 8 end - local mask_key = nil + local mask_key if masked then if #data < offset + 4 then return nil @@ -76,8 +77,7 @@ function M.encode_frame(payload, opcode, masked) local len = #payload local frame = {} - local byte1 = bit.bor(0x80, opcode) - table.insert(frame, string.char(byte1)) + table.insert(frame, string.char(bit.bor(0x80, opcode))) local byte2 = masked and 0x80 or 0x00 diff --git a/lua/opencode/editor/handshake.lua b/lua/opencode/server/websocket/handshake.lua similarity index 82% rename from lua/opencode/editor/handshake.lua rename to lua/opencode/server/websocket/handshake.lua index 5afb95b3..7a8cf64a 100644 --- a/lua/opencode/editor/handshake.lua +++ b/lua/opencode/server/websocket/handshake.lua @@ -1,5 +1,6 @@ +local utils = require("opencode.server.websocket.utils") + local M = {} -local utils = require("opencode.editor.utils") function M.validate_upgrade_request(request, expected_auth_token) local headers = utils.parse_http_headers(request) @@ -35,20 +36,16 @@ function M.validate_upgrade_request(request, expected_auth_token) end function M.create_accept_key(websocket_key) - local GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" - local combined = websocket_key .. GUID - local sha1_hash = utils.sha1(combined) - return utils.base64_encode(sha1_hash) + local combined = websocket_key .. "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + return utils.base64_encode(utils.sha1(combined)) end function M.create_response(websocket_key, auth_token) - local accept_key = M.create_accept_key(websocket_key) - local response = { "HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", - "Sec-WebSocket-Accept: " .. accept_key, + "Sec-WebSocket-Accept: " .. M.create_accept_key(websocket_key), } if auth_token then diff --git a/lua/opencode/server/websocket/init.lua b/lua/opencode/server/websocket/init.lua new file mode 100644 index 00000000..ce87bc27 --- /dev/null +++ b/lua/opencode/server/websocket/init.lua @@ -0,0 +1,176 @@ +local M = {} +local Client = require("opencode.server.websocket.client") +local handshake = require("opencode.server.websocket.handshake") +local tcp_server = require("opencode.server.websocket.tcp") + +M.state = { + server = nil, + clients = {}, + port = nil, + auth_token = nil, +} + +local function remove_client(client) + for i, connected_client in ipairs(M.state.clients) do + if connected_client == client then + table.remove(M.state.clients, i) + break + end + end +end + +local function broadcast(method, params) + local message = { + jsonrpc = "2.0", + method = method, + params = params or {}, + } + + local sent = false + for _, client in ipairs(M.state.clients) do + if client:is_connected() then + client:send_json(message) + sent = true + end + end + return sent +end + +function M.start(port, auth_token) + if M.state.server then + return false, "Server already running on port " .. M.state.port + end + + port = port or 0 + auth_token = auth_token or nil + + local server, err = tcp_server.create_server("127.0.0.1", port, function(tcp_client) + local client = Client.new(tcp_client) + + local function handle_message(type, data) + if type == "handshake" then + local ok, headers = handshake.validate_upgrade_request(data, auth_token) + if not ok then + local response = "HTTP/1.1 400 Bad Request\r\n\r\n" .. (headers or "Invalid handshake") + client:send(response) + client:close() + remove_client(client) + return + end + + local ws_key = headers["sec-websocket-key"] + local response = handshake.create_response(ws_key, auth_token) + client:send(response) + client:set_state("connected") + client.authenticated = auth_token and true or false + + table.insert(M.state.clients, client) + elseif type == "message" then + local ok, message = pcall(vim.json.decode, data) + if ok and message then + if message.method == "initialize" then + local response = { + jsonrpc = "2.0", + id = message.id, + result = { + protocolVersion = "2025-11-25", + serverInfo = { + name = "opencode.nvim", + version = "1.0.0", + }, + }, + } + client:send_json(response) + elseif message.method == "notifications/initialized" then + vim.schedule(function() + require("opencode.editor.selection").update(true) + end) + end + end + end + end + + local function handle_close() + remove_client(client) + end + + tcp_client:read_start(function(read_err, data) + if read_err or not data then + client:close() + remove_client(client) + return + end + + client:handle_data(data, handle_message, handle_close) + end) + end) + + if not server then + return false, err or "Failed to create server" + end + + local sockname = server:getsockname() + local actual_port = sockname.port + + M.state.server = server + M.state.port = actual_port + M.state.auth_token = auth_token + + return true, actual_port +end + +function M.stop() + for _, client in ipairs(M.state.clients) do + client:close() + end + M.state.clients = {} + + if M.state.server then + M.state.server:close() + M.state.server = nil + end + + M.state.port = nil + M.state.auth_token = nil +end + +function M.is_running() + return M.state.server ~= nil +end + +function M.get_port() + return M.state.port +end + +function M.get_auth_token() + return M.state.auth_token +end + +function M.broadcast_selection_changed(file_path, selection) + return broadcast("selection_changed", { + text = selection.text or "", + filePath = file_path, + fileUrl = "file://" .. file_path, + selection = { + start = { + line = selection.start_line, + character = selection.start_col, + }, + ["end"] = { + line = selection.end_line, + character = selection.end_col, + }, + isEmpty = selection.is_empty or false, + }, + }) +end + +function M.broadcast_at_mentioned(file_path, line_start, line_end) + return broadcast("at_mentioned", { + filePath = file_path, + lineStart = line_start, + lineEnd = line_end, + }) +end + +return M diff --git a/lua/opencode/editor/tcp.lua b/lua/opencode/server/websocket/tcp.lua similarity index 92% rename from lua/opencode/editor/tcp.lua rename to lua/opencode/server/websocket/tcp.lua index b4607166..da4e995b 100644 --- a/lua/opencode/editor/tcp.lua +++ b/lua/opencode/server/websocket/tcp.lua @@ -1,7 +1,7 @@ -local M = {} - local uv = vim.uv +local M = {} + function M.create_server(host, port, on_connection) local server = uv.new_tcp() if not server then @@ -14,8 +14,8 @@ function M.create_server(host, port, on_connection) return nil, err or "Failed to bind to port" end - ok, err = server:listen(128, function(err) - if err then + ok, err = server:listen(128, function(listen_err) + if listen_err then return end diff --git a/lua/opencode/editor/utils.lua b/lua/opencode/server/websocket/utils.lua similarity index 87% rename from lua/opencode/editor/utils.lua rename to lua/opencode/server/websocket/utils.lua index e182757c..c6aa719f 100644 --- a/lua/opencode/editor/utils.lua +++ b/lua/opencode/server/websocket/utils.lua @@ -65,12 +65,10 @@ function M.sha1(str) local zero_bytes = (56 - (#msg % 64)) % 64 msg = msg .. string.rep("\0", zero_bytes) - msg = msg .. M.pack_u64(len) - local chunk_size = 64 - for i = 1, #msg, chunk_size do - local chunk = string.sub(msg, i, i + chunk_size - 1) + for i = 1, #msg, 64 do + local chunk = string.sub(msg, i, i + 63) local words = {} for j = 1, 16 do @@ -80,8 +78,7 @@ function M.sha1(str) end for j = 17, 80 do - local w = bit.bxor(words[j - 3], words[j - 8], words[j - 14], words[j - 16]) - words[j] = bit.rol(w, 1) + words[j] = bit.rol(bit.bxor(words[j - 3], words[j - 8], words[j - 14], words[j - 16]), 1) end local a, b, c, d, e = h0, h1, h2, h3, h4 @@ -127,7 +124,6 @@ function M.base64_encode(str) local b1 = string.byte(str, i) local b2 = string.byte(str, i + 1) or 0 local b3 = string.byte(str, i + 2) or 0 - local n = b1 * 65536 + b2 * 256 + b3 local c1 = math.floor(n / 262144) % 64 @@ -137,18 +133,8 @@ function M.base64_encode(str) table.insert(result, string.sub(b64chars, c1 + 1, c1 + 1)) table.insert(result, string.sub(b64chars, c2 + 1, c2 + 1)) - - if i + 1 <= #str then - table.insert(result, string.sub(b64chars, c3 + 1, c3 + 1)) - else - table.insert(result, "=") - end - - if i + 2 <= #str then - table.insert(result, string.sub(b64chars, c4 + 1, c4 + 1)) - else - table.insert(result, "=") - end + table.insert(result, i + 1 <= #str and string.sub(b64chars, c3 + 1, c3 + 1) or "=") + table.insert(result, i + 2 <= #str and string.sub(b64chars, c4 + 1, c4 + 1) or "=") end return table.concat(result) @@ -171,11 +157,7 @@ function M.parse_http_headers(request) end function M.constant_time_compare(a, b) - if type(a) ~= "string" or type(b) ~= "string" then - return false - end - - if #a ~= #b then + if type(a) ~= "string" or type(b) ~= "string" or #a ~= #b then return false end diff --git a/plugin/live-context.lua b/plugin/live-context.lua index d49ad80e..977dcaf4 100644 --- a/plugin/live-context.lua +++ b/plugin/live-context.lua @@ -24,7 +24,7 @@ end, { range = true, desc = "Attach visual selection to OpenCode context (no sub if live_context and live_context.enabled then vim.schedule(function() - if not require("opencode.editor").is_running() then + if not require("opencode.server.websocket").is_running() then start(live_context) end end) @@ -33,7 +33,7 @@ end vim.api.nvim_create_autocmd("VimLeavePre", { group = vim.api.nvim_create_augroup("OpenCodeLiveContextShutdown", { clear = true }), callback = function() - if require("opencode.editor").is_running() then + if require("opencode.server.websocket").is_running() then require("opencode").stop_live_context() end end, From b4d038b4682403009901dbade86ce633bcb63d99 Mon Sep 17 00:00:00 2001 From: itsmeyaw Date: Fri, 7 Aug 2026 18:37:21 +0200 Subject: [PATCH 4/9] chore(lockfile): add note regarding .claude lockfile --- lua/opencode/editor/lockfile.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lua/opencode/editor/lockfile.lua b/lua/opencode/editor/lockfile.lua index bb8019de..43f8b00f 100644 --- a/lua/opencode/editor/lockfile.lua +++ b/lua/opencode/editor/lockfile.lua @@ -1,6 +1,9 @@ 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 From 7016d6ea7fb67f041ae4665761b5711fe5af0517 Mon Sep 17 00:00:00 2001 From: itsmeyaw Date: Fri, 7 Aug 2026 19:28:21 +0200 Subject: [PATCH 5/9] docs: change the configuration for lazy.vim --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2ed00b2d..db2d8351 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,9 @@ vim.keymap.set({ "n" }, "", function() require("opencode").command(" 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" }, "", function() require("opencode").ask("@this: ") end, { desc = "Ask OpenCode…" }) vim.keymap.set({ "n", "x" }, "", function() require("opencode").select() end, { desc = "Select OpenCode…" }) From acdb272e417e9ac03cd6678d2f7365247a21a546 Mon Sep 17 00:00:00 2001 From: itsmeyaw Date: Fri, 7 Aug 2026 19:54:08 +0200 Subject: [PATCH 6/9] fix(websocket): add nil check for socket name --- lua/opencode/server/websocket/init.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/opencode/server/websocket/init.lua b/lua/opencode/server/websocket/init.lua index ce87bc27..4bb6c1b9 100644 --- a/lua/opencode/server/websocket/init.lua +++ b/lua/opencode/server/websocket/init.lua @@ -110,6 +110,10 @@ function M.start(port, auth_token) end local sockname = server:getsockname() + if not sockname then + server:close() + return false, "Failed to determine server port" + end local actual_port = sockname.port M.state.server = server @@ -150,7 +154,7 @@ function M.broadcast_selection_changed(file_path, selection) return broadcast("selection_changed", { text = selection.text or "", filePath = file_path, - fileUrl = "file://" .. file_path, + fileUrl = vim.uri_from_fname(file_path), selection = { start = { line = selection.start_line, From d9a388d624402dea3d71c4eb4310f50133198c77 Mon Sep 17 00:00:00 2001 From: itsmeyaw Date: Fri, 7 Aug 2026 20:02:23 +0200 Subject: [PATCH 7/9] fix(lockfile): corrected parameter for mkdir --- lua/opencode/editor/lockfile.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/editor/lockfile.lua b/lua/opencode/editor/lockfile.lua index 43f8b00f..666a9eac 100644 --- a/lua/opencode/editor/lockfile.lua +++ b/lua/opencode/editor/lockfile.lua @@ -41,7 +41,7 @@ function M.create(port, auth_token) end local lockfile_dir = get_lockfile_dir() - if vim.fn.mkdir(lockfile_dir, "p", tonumber("700", 8)) == 0 and vim.fn.isdirectory(lockfile_dir) == 0 then + 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)) From 2dcdd5d97c2c1b45ae26f942f8eff3519b9434e8 Mon Sep 17 00:00:00 2001 From: itsmeyaw Date: Fri, 7 Aug 2026 20:05:22 +0200 Subject: [PATCH 8/9] fix(ws-client): conform RFC 6455 for requiring masked incoming frame from client --- lua/opencode/server/websocket/client.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/opencode/server/websocket/client.lua b/lua/opencode/server/websocket/client.lua index 709a6ab9..8edb81fe 100644 --- a/lua/opencode/server/websocket/client.lua +++ b/lua/opencode/server/websocket/client.lua @@ -60,6 +60,12 @@ function Client:handle_data(data, on_message, on_close) self.buffer = self.buffer:sub(decoded.consumed + 1) + if not decoded.masked then + self:close() + on_close() + return + end + if decoded.opcode == 0x8 then self:close() on_close() From 9d41f8f0de2940c500dc6255b30ac4ccd018f8d4 Mon Sep 17 00:00:00 2001 From: itsmeyaw Date: Fri, 7 Aug 2026 20:06:55 +0200 Subject: [PATCH 9/9] chore(style): corrected code for style --- lua/opencode/editor/selection.lua | 18 +++++++++++------- lua/opencode/server/websocket/frame.lua | 1 + 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lua/opencode/editor/selection.lua b/lua/opencode/editor/selection.lua index 8c54ff62..d23f1923 100644 --- a/lua/opencode/editor/selection.lua +++ b/lua/opencode/editor/selection.lua @@ -161,13 +161,17 @@ local function debounce_send_selection() end M.state.debounce_timer = timer - timer:start(100, 0, vim.schedule_wrap(function() - if M.state.debounce_timer == timer then - M.state.debounce_timer = nil - timer:close() - M.update() - end - end)) + timer:start( + 100, + 0, + vim.schedule_wrap(function() + if M.state.debounce_timer == timer then + M.state.debounce_timer = nil + timer:close() + M.update() + end + end) + ) end local function on_cursor_moved() diff --git a/lua/opencode/server/websocket/frame.lua b/lua/opencode/server/websocket/frame.lua index d3ecc8ce..b0614934 100644 --- a/lua/opencode/server/websocket/frame.lua +++ b/lua/opencode/server/websocket/frame.lua @@ -65,6 +65,7 @@ function M.decode_frame(data) return { fin = fin, opcode = opcode, + masked = masked, payload = payload, consumed = offset + payload_len, }