diff --git a/README.md b/README.md index 256084b4..db2d8351 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 @@ -56,12 +57,14 @@ 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 } + 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…" }) @@ -296,6 +299,41 @@ Prompt OpenCode. Wraps Prompt as an operator, supporting ranges and dot-repeat. +### Live Context — `require("opencode").start_live_context()` + +Keep OpenCode aware of the file and visual selection you are working in. + +Set these options before the plugin loads. To start Live Context automatically, configure your plugin manager to load opencode.nvim at startup. + +```lua +vim.g.opencode_opts = { + live_context = { + enabled = true, -- Start when the plugin loads + port = 0, -- Use an available port + auth_token = true, -- Optional: generate a per-process token + }, +} + +-- Or start manually with the configured options. +vim.keymap.set("n", "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" }) +``` + +`attach_context()` emits `at_mentioned`. OpenCode inserts the file reference into its prompt without submitting it, leaving the prompt ready for more text. + +| API or command | Description | +| --- | --- | +| `start_live_context(opts)` / `:OpenCodeLiveContextStart` | Start the server and selection tracking | +| `stop_live_context()` / `:OpenCodeLiveContextStop` | Stop the server and remove its lockfile | +| `attach_context()` / `:'<,'>OpenCodeAttach` | Insert the selected file range into the TUI prompt | + +For a fixed port, set `port` and launch OpenCode with `OPENCODE_EDITOR_SSE_PORT` or `CLAUDE_CODE_SSE_PORT` set to the same value. This bypasses lockfile discovery. + ### Command — `require("opencode").command()` Command OpenCode: diff --git a/lua/opencode.lua b/lua/opencode.lua index 8cef9bb0..8fc18c4f 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -110,4 +110,77 @@ end M.format = require("opencode.context").format +M.editor = { + server = require("opencode.server.websocket"), + selection = require("opencode.editor.selection"), + lockfile = require("opencode.editor.lockfile"), +} + +function M.start_live_context(opts) + opts = vim.tbl_deep_extend("force", {}, require("opencode.config").opts.live_context or {}, opts or {}) + local port = opts.port or 0 + local auth_token = opts.auth_token + + if M.editor.server.is_running() then + return true, M.editor.server.get_port() + end + + if auth_token == true then + local token_err + auth_token, token_err = M.editor.lockfile.generate_auth_token() + if not auth_token then + return false, "Failed to generate authentication token: " .. (token_err or "unknown error") + end + elseif auth_token ~= nil and type(auth_token) ~= "string" then + return false, "Authentication token must be a string or true" + end + + M.editor.lockfile.clean_all() + + local server_ok, server_result = M.editor.server.start(port, auth_token) + if not server_ok then + return false, server_result + end + + local actual_port = server_result + + local lock_ok, lock_result = M.editor.lockfile.create(actual_port, auth_token) + if not lock_ok then + M.editor.server.stop() + return false, lock_result + end + + M.editor.selection.enable() + + return true, actual_port +end + +function M.stop_live_context() + local port = M.editor.server.get_port() + + M.editor.selection.disable() + M.editor.server.stop() + + if port then + M.editor.lockfile.remove(port) + end +end + +function M.attach_context(line_start, line_end) + local selection = require("opencode.editor.selection") + local ok, err + + if line_start and line_end then + ok, err = selection.send_range_as_mention(line_start, line_end) + else + ok, err = selection.send_visual_selection_as_mention() + end + + if not ok then + vim.notify(err or "Failed to attach context", vim.log.levels.WARN, { title = "OpenCode" }) + end + + return ok, err +end + return M diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index ab6f80a8..29167e72 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`. diff --git a/lua/opencode/editor/init.lua b/lua/opencode/editor/init.lua new file mode 100644 index 00000000..d507c490 --- /dev/null +++ b/lua/opencode/editor/init.lua @@ -0,0 +1 @@ +return require("opencode.server.websocket") diff --git a/lua/opencode/editor/lockfile.lua b/lua/opencode/editor/lockfile.lua new file mode 100644 index 00000000..666a9eac --- /dev/null +++ b/lua/opencode/editor/lockfile.lua @@ -0,0 +1,154 @@ +local M = {} + +local function get_lockfile_dir() + -- Opencode actually read the lockfile inside .claude + -- for some reason + -- See: https://github.com/anomalyco/opencode/blob/dev/packages/tui/src/editor.ts + return vim.fn.expand("~/.claude/ide") +end + +local function get_lockfile_path(port) + return get_lockfile_dir() .. "/" .. port .. ".lock" +end + +local function get_workspace_folders() + local folders = {} + + local cwd = vim.fn.getcwd() + table.insert(folders, cwd) + + return folders +end + +function M.generate_auth_token() + local bytes, err = vim.uv.random(16) + if not bytes then + return nil, err or "Failed to obtain random bytes" + end + + return (bytes:gsub(".", function(byte) + return string.format("%02x", string.byte(byte)) + end)) +end + +function M.create(port, auth_token) + if not port or port <= 0 or port > 65535 then + return false, "Invalid port number" + end + + if auth_token ~= nil and type(auth_token) ~= "string" then + return false, "Authentication token must be a string" + end + + local lockfile_dir = get_lockfile_dir() + if vim.fn.mkdir(lockfile_dir, "p", "0700") == 0 and vim.fn.isdirectory(lockfile_dir) == 0 then + return false, "Failed to create lockfile directory" + end + pcall(vim.uv.fs_chmod, lockfile_dir, tonumber("700", 8)) + + local lockfile_path = get_lockfile_path(port) + + local lock_content = { + pid = vim.fn.getpid(), + workspaceFolders = get_workspace_folders(), + ideName = "Neovim", + transport = "ws", + } + + if auth_token then + lock_content.authToken = auth_token + end + + local json = vim.json.encode(lock_content) + + local temp_file = lockfile_path .. ".tmp." .. vim.fn.getpid() + local fd = io.open(temp_file, "wb") + if not fd then + return false, "Failed to create temporary lockfile" + end + + fd:write(json) + fd:close() + + local ok, err = os.rename(temp_file, lockfile_path) + if not ok then + os.remove(temp_file) + return false, "Failed to create lockfile: " .. (err or "unknown error") + end + + return true, lockfile_path +end + +function M.remove(port) + if not port then + return false + end + + local lockfile_path = get_lockfile_path(port) + + if vim.fn.filereadable(lockfile_path) == 1 then + os.remove(lockfile_path) + return true + end + + return false +end + +function M.exists(port) + if not port then + return false + end + + local lockfile_path = get_lockfile_path(port) + return vim.fn.filereadable(lockfile_path) == 1 +end + +function M.read(port) + if not port then + return nil + end + + local lockfile_path = get_lockfile_path(port) + + if vim.fn.filereadable(lockfile_path) == 0 then + return nil + end + + local fd = io.open(lockfile_path, "r") + if not fd then + return nil + end + + local content = fd:read("*a") + fd:close() + + local ok, data = pcall(vim.json.decode, content) + if not ok then + return nil + end + + return data +end + +function M.clean_all() + local lockfile_dir = get_lockfile_dir() + + if vim.fn.isdirectory(lockfile_dir) == 0 then + return + end + + local files = vim.fn.glob(lockfile_dir .. "/*.lock", true, true) + for _, file in ipairs(files) do + local port = vim.fn.fnamemodify(file, ":t:r") + local lock_data = M.read(tonumber(port)) + + if lock_data and lock_data.pid then + local pid = lock_data.pid + if not vim.uv.kill(pid, 0) then + os.remove(file) + end + end + end +end + +return M diff --git a/lua/opencode/editor/selection.lua b/lua/opencode/editor/selection.lua new file mode 100644 index 00000000..d23f1923 --- /dev/null +++ b/lua/opencode/editor/selection.lua @@ -0,0 +1,295 @@ +local M = {} +local server = require("opencode.server.websocket") + +M.state = { + last_selection = nil, + enabled = false, + 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.api.nvim_get_mode().mode + + if not is_visual_mode(mode) then + return nil + end + + local anchor = vim.fn.getpos("v") + local cursor = vim.api.nvim_win_get_cursor(0) + + 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] + local end_line = end_pos[1] + local end_col = end_pos[2] + + local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) + 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, + 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] + + return { + start_line = line - 1, + start_col = col, + end_line = line - 1, + end_col = col, + text = "", + 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.api.nvim_get_mode().mode + local selection + + if is_visual_mode(mode) 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:%s", + selection.file_path, + selection.selection.start_line, + selection.selection.start_col, + selection.selection.end_line, + selection.selection.end_col, + selection.selection.text + ) +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 + +function M.update(force) + if not server.is_running() then + return + end + + local current = get_current_selection() + + 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 +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 + + 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 + +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, + }) + + M.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, "Live context is not running" + end + + 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() + 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, "Current buffer is not a file" + end + + local selection = get_visual_selection() + if not selection then + 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 + + return M.send_at_mention(filepath, line_start - 1, line_end - 1) +end + +return M diff --git a/lua/opencode/server/websocket/client.lua b/lua/opencode/server/websocket/client.lua new file mode 100644 index 00000000..8edb81fe --- /dev/null +++ b/lua/opencode/server/websocket/client.lua @@ -0,0 +1,98 @@ +local frame = require("opencode.server.websocket.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) + 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.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 = pcall(on_message, "handshake", request) + if not ok then + self:close() + on_close() + return + end + elseif self.state == "connected" then + local decoded = frame.decode_frame(self.buffer) + if not decoded then + break + end + + 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() + return + elseif decoded.opcode == 0x9 then + self:send(frame.pong_frame()) + elseif decoded.opcode == 0xA then + elseif decoded.opcode == 0x1 then + local ok = pcall(on_message, "message", decoded.payload) + if not ok then + self:close() + on_close() + return + 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 Client diff --git a/lua/opencode/server/websocket/frame.lua b/lua/opencode/server/websocket/frame.lua new file mode 100644 index 00000000..b0614934 --- /dev/null +++ b/lua/opencode/server/websocket/frame.lua @@ -0,0 +1,129 @@ +local bit = require("bit") +local utils = require("opencode.server.websocket.utils") + +local M = {} + +local OPCODE_TEXT = 0x1 +local OPCODE_CLOSE = 0x8 +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 = bit.band(byte1, 0x80) ~= 0 + local opcode = bit.band(byte1, 0x0F) + + local masked = bit.band(byte2, 0x80) ~= 0 + local payload_len = bit.band(byte2, 0x7F) + + local offset = 2 + + if payload_len == 126 then + if #data < offset + 2 then + return nil + end + 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 = utils.unpack_u64(data, offset + 1) + offset = offset + 8 + end + + local mask_key + 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(bit.bxor(byte, mask_byte))) + end + payload = table.concat(decoded) + end + + return { + fin = fin, + opcode = opcode, + masked = masked, + 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 = {} + + table.insert(frame, string.char(bit.bor(0x80, opcode))) + + local byte2 = masked and 0x80 or 0x00 + + if len <= 125 then + table.insert(frame, string.char(bit.bor(byte2, len))) + elseif len <= 65535 then + table.insert(frame, string.char(bit.bor(byte2, 126))) + table.insert(frame, utils.pack_u16(len)) + else + table.insert(frame, string.char(bit.bor(byte2, 127))) + table.insert(frame, utils.pack_u64(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(bit.bxor(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.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/server/websocket/handshake.lua b/lua/opencode/server/websocket/handshake.lua new file mode 100644 index 00000000..7a8cf64a --- /dev/null +++ b/lua/opencode/server/websocket/handshake.lua @@ -0,0 +1,61 @@ +local utils = require("opencode.server.websocket.utils") + +local M = {} + +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 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 response = { + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Accept: " .. M.create_accept_key(websocket_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/server/websocket/init.lua b/lua/opencode/server/websocket/init.lua new file mode 100644 index 00000000..4bb6c1b9 --- /dev/null +++ b/lua/opencode/server/websocket/init.lua @@ -0,0 +1,180 @@ +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() + if not sockname then + server:close() + return false, "Failed to determine server port" + end + 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 = vim.uri_from_fname(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/server/websocket/tcp.lua b/lua/opencode/server/websocket/tcp.lua new file mode 100644 index 00000000..da4e995b --- /dev/null +++ b/lua/opencode/server/websocket/tcp.lua @@ -0,0 +1,57 @@ +local uv = vim.uv + +local M = {} + +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(listen_err) + if listen_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/server/websocket/utils.lua b/lua/opencode/server/websocket/utils.lua new file mode 100644 index 00000000..c6aa719f --- /dev/null +++ b/lua/opencode/server/websocket/utils.lua @@ -0,0 +1,172 @@ +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 + 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 .. M.pack_u64(len) + + for i = 1, #msg, 64 do + local chunk = string.sub(msg, i, i + 63) + local words = {} + + for j = 1, 16 do + 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 + 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 + + for j = 1, 80 do + local f, k + if j <= 20 then + f = bit.bor(bit.band(b, c), bit.band(bit.bnot(b), d)) + k = 0x5A827999 + elseif j <= 40 then + f = bit.bxor(b, c, d) + k = 0x6ED9EBA1 + elseif j <= 60 then + f = bit.bor(bit.band(b, c), bit.band(b, d), bit.band(c, d)) + k = 0x8F1BBCDC + else + f = bit.bxor(b, c, d) + k = 0xCA62C1D6 + end + + local temp = bit.band(bit.rol(a, 5) + f + e + k + words[j], 0xFFFFFFFF) + e = d + d = c + c = bit.rol(b, 30) + b = a + a = temp + end + + 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 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 = {} + 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)) + 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) +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" or #a ~= #b then + return false + end + + local result = 0 + for i = 1, #a do + result = bit.bor(result, bit.bxor(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..977dcaf4 --- /dev/null +++ b/plugin/live-context.lua @@ -0,0 +1,41 @@ +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() + 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(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.server.websocket").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.server.websocket").is_running() then + require("opencode").stop_live_context() + end + end, + desc = "Stop OpenCode live context and remove its lockfile", +})