This document provides technical details about the claudecode.nvim implementation for developers and contributors.
The plugin implements a WebSocket server in pure Lua that speaks the same protocol as Anthropic's official IDE extensions. It's built entirely with Neovim built-ins (vim.loop, vim.json) with zero external dependencies.
A complete RFC 6455 WebSocket implementation in pure Lua:
-- server/tcp.lua - TCP server using vim.loop
local tcp = vim.loop.new_tcp()
tcp:bind("127.0.0.1", port) -- Always localhost!
tcp:listen(128, on_connection)
-- server/handshake.lua - HTTP upgrade handling
-- Validates Sec-WebSocket-Key, generates Accept header
local accept_key = base64(sha1(key .. WEBSOCKET_GUID))
-- server/frame.lua - WebSocket frame parser
-- Handles fragmentation, masking, control frames
local opcode = bit.band(byte1, 0x0F)
local masked = bit.band(byte2, 0x80) ~= 0
local payload_len = bit.band(byte2, 0x7F)
-- server/client.lua - Connection management
-- Tracks state, handles ping/pong, manages cleanupKey implementation details:
- Uses
vim.schedule()for thread-safe Neovim API calls - Implements SHA-1 in pure Lua for WebSocket handshake
- Handles all WebSocket opcodes (text, binary, close, ping, pong)
- Automatic ping/pong keepalive every 30 seconds
Manages discovery files for Claude CLI:
-- Atomic file writing to prevent partial reads
local temp_path = lock_path .. ".tmp"
write_file(temp_path, json_data)
vim.loop.fs_rename(temp_path, lock_path)
-- Cleanup on exit
vim.api.nvim_create_autocmd("VimLeavePre", {
callback = function()
vim.loop.fs_unlink(lock_path)
end
})Dynamic tool registration with JSON schema validation:
-- Tool registration
M.register("openFile", {
type = "object",
properties = {
filePath = { type = "string", description = "Path to open" }
},
required = { "filePath" }
}, function(params)
-- Implementation
vim.cmd("edit " .. params.filePath)
return { content = {{ type = "text", text = "Opened" }} }
end)
-- Automatic MCP tool list generation
function M.get_tool_list()
local tools = {}
for name, tool in pairs(registry) do
if tool.schema then -- Only expose tools with schemas
table.insert(tools, {
name = name,
description = tool.schema.description,
inputSchema = tool.schema
})
end
end
return tools
endNative Neovim diff implementation:
-- Create temp file with proposed changes
local temp_file = vim.fn.tempname()
write_file(temp_file, new_content)
-- Open diff in current tab to reduce clutter
vim.cmd("edit " .. original_file)
vim.cmd("diffthis")
vim.cmd("vsplit " .. temp_file)
vim.cmd("diffthis")
-- Custom keymaps for diff mode
vim.keymap.set("n", "<leader>da", accept_all_changes)
vim.keymap.set("n", "<leader>dq", exit_diff_mode)Debounced selection monitoring:
-- Track selection changes with debouncing
local timer = nil
vim.api.nvim_create_autocmd("CursorMoved", {
callback = function()
if timer then timer:stop() end
timer = vim.defer_fn(send_selection_update, 50)
end
})
-- Visual mode demotion delay
-- Preserves selection context when switching to terminalFlexible terminal management with provider pattern:
-- Snacks.nvim provider (preferred)
if has_snacks then
Snacks.terminal.open(cmd, {
win = { position = "right", width = 0.3 }
})
else
-- Native fallback
vim.cmd("vsplit | terminal " .. cmd)
endAll Neovim API calls from async contexts use vim.schedule():
client:on("message", function(data)
vim.schedule(function()
-- Safe to use vim.* APIs here
end)
end)Consistent error propagation pattern:
local ok, result = pcall(risky_operation)
if not ok then
logger.error("Operation failed: " .. tostring(result))
return false, result
end
return true, resultAutomatic cleanup on shutdown:
vim.api.nvim_create_autocmd("VimLeavePre", {
callback = function()
M.stop() -- Stop server, remove lock file
end
})lua/claudecode/
├── init.lua # Plugin entry point
├── config.lua # Configuration management
├── server/ # WebSocket implementation
│ ├── tcp.lua # TCP server (vim.loop)
│ ├── handshake.lua # HTTP upgrade handling
│ ├── frame.lua # RFC 6455 frame parser
│ ├── client.lua # Connection management
│ └── utils.lua # Pure Lua SHA-1, base64
├── tools/init.lua # MCP tool registry
├── diff.lua # Native diff support
├── selection.lua # Selection tracking
├── terminal.lua # Terminal management
└── lockfile.lua # Discovery files
Three-layer testing strategy using busted:
-- Unit tests: isolated function testing
describe("frame parser", function()
it("handles masked frames", function()
local frame = parse_frame(masked_data)
assert.equals("hello", frame.payload)
end)
end)
-- Component tests: subsystem testing
describe("websocket server", function()
it("accepts connections", function()
local server = Server:new()
server:start(12345)
-- Test connection logic
end)
end)
-- Integration tests: end-to-end with mock Claude
describe("full flow", function()
it("handles tool calls", function()
local mock_claude = create_mock_client()
-- Test complete message flow
end)
end)Manual testing with real Neovim configurations in the fixtures/ directory:
# Test with different file explorers
source fixtures/nvim-aliases.sh
vv nvim-tree # Test with nvim-tree integration
vv oil # Test with oil.nvim integration
vv mini-files # Test with mini.files integration
vv netrw # Test with built-in netrw
# Each fixture provides:
# - Complete Neovim configuration
# - Plugin dependencies
# - Development keybindings
# - Integration-specific testing scenariosFixture Architecture:
fixtures/bin/- Helper scripts (vv,vve,list-configs)fixtures/[integration]/- Complete Neovim configs for testingfixtures/nvim-aliases.sh- Shell aliases for easy testing
- Debounced Updates: 50ms delay on selection changes
- Localhost Only: Server binds to 127.0.0.1
- Resource Cleanup: Automatic on vim exit
- Memory Efficient: Minimal footprint, no caching
- Async I/O: Non-blocking vim.loop operations