claudecode is the core package of claudecode.nvim. It makes Neovim an IDE peer for the Claude Code CLI. It does six jobs:
- It starts a localhost MCP WebSocket server (
./server/README.md). - It writes a discovery lock file that holds an auth token.
- It launches the CLI in a terminal provider (
./terminal/README.md) with env vars that point the CLI at the server. - It serves MCP tool calls (
./tools/README.md). - It streams selection and @-mention notifications to the CLI.
- It shows blocking accept/reject diffs for the edits that Claude proposes.
Module map (direct files):
| File | Role |
|---|---|
init.lua |
Entry point: setup, start/stop, user commands, @-mention queue |
config.lua |
Defaults, legacy-option mapping, and assert-based validation |
lockfile.lua |
<port>.lock discovery file and auth token |
terminal.lua |
Provider dispatcher: command, env, cwd, send_to_terminal |
diff.lua |
Native side-by-side blocking diff and the diff lifecycle |
diff_inline.lua |
Unified (layout = "unified") inline diff renderer |
selection.lua |
Visual and cursor selection tracking and the selection_changed broadcast |
integrations.lua |
File-explorer selection readers (nvim-tree, neo-tree, oil, mini.files, netrw, snacks picker) |
visual_commands.lua |
Visual-mode command wrapper and tree-explorer line-to-path mapping |
cwd.lua |
Terminal working-directory resolution and git root lookup |
utils.lua |
Shell-free argv parsing, tilde expansion, the selection type of a mode, and UTF-8 repair |
logger.lua |
Level-filtered logging to vim.notify and nvim_echo |
health.lua |
:checkhealth claudecode |
types.lua |
LuaLS @class and @alias declarations. Its only runtime code is return {} |
./server/README.md: WebSocket (RFC 6455) and JSON-RPC 2.0 MCP server. API:start(config, auth_token),stop(),send,send_response,broadcast, andget_status()../terminal/README.md: thenative,snacks,external, andnoneproviders, pluspaste_fixfor streamed pastes../tools/README.md: the MCP tool registry. It exposes 10 tools and the hiddenclose_tab.
M.setup(opts) -> M: applies config, sets up the logger, terminal, and diff modules, and creates commands and the shutdown autocmd. It starts the server whenauto_startis set [init.lua:M.setup]M.start(show_startup_notification?) -> ok, port|errandM.stop() -> ok, err?[init.lua:M.start]M.state:config,server,port,auth_token,initialized,mention_queue,mention_timer, andconnection_timer[init.lua:M.state]M.is_claude_connected(): false when the server is not running. Whenget_status().clientsis non-empty, true only when a client is in stateconnectedwithhandshake_complete, so aclosingclient does not count. When it is empty, true whenclient_count > 0, with no handshake check [init.lua:M.is_claude_connected]M.send_at_mention(file_path, start_line?, end_line?, context?) -> ok, err?: line numbers are 0-indexed [init.lua:M.send_at_mention]M.process_mention_queue(from_new_connection?): the server'son_opencallback calls it withtrueonce a client completed its WebSocket handshake. Without the argument it sends the oldest queued mention now [init.lua:M.process_mention_queue]M.open_with_model(args),M.get_version(), andM.version, read from the repository'sVERSIONfile at load [init.lua:M.version]- Internal API:
M._create_commands,M._broadcast_at_mention,M._add_paths_to_claude(defaultmax_files100,batch_size10),M._format_path_for_at_mention,M._ensure_terminal_visible_if_connected, andM._maybe_warn_unfocusable_provider[init.lua:M._add_paths_to_claude]
- Server:
:ClaudeCodeStart,:ClaudeCodeStop, and:ClaudeCodeStatus. - Context:
:ClaudeCodeSend(range, tree-aware),:ClaudeCodeTreeAdd, and:ClaudeCodeAdd <path> [start] [end]. - Terminal:
:ClaudeCode [args],:ClaudeCodeFocus [args],:ClaudeCodeOpen [args],:ClaudeCodeClose, and:ClaudeCodeSendText[!] <text>. These commands exist only whenclaudecode.terminalloads. - Diff:
:ClaudeCodeDiffAccept,:ClaudeCodeDiffDeny, and:ClaudeCodeCloseAllDiffs. - Model:
:ClaudeCodeSelectModel [args].
User ClaudeCodeSendCompletewithdata = { file_path, start_line, end_line, context }[init.lua:fire_send_complete]User ClaudeCodeDiffOpenedandUser ClaudeCodeDiffClosed[diff.lua:fire_diff_event]
M.defaults,M.apply(user_config) -> config, andM.validate(config) -> true[config.lua:M.apply]- Defaults:
port_range = { min = 10000, max = 65535 },auto_start = true,log_level = "info",track_selection = true,focus_after_send = false,visual_demotion_delay_ms = 50,connection_wait_delay = 600,connection_timeout = 10000,queue_timeout = 5000,terminal.provider = "auto", and sixmodelsentries [config.lua:M.defaults] diff_optsdefaults:layout = "vertical",open_in_new_tab = false,keep_terminal_focus = false,hide_terminal_in_new_tab = false,on_new_file_reject = "keep_empty", andauto_resize_terminal = true[config.lua:M.defaults]
terminal.lua:setup,open,close,simple_toggle,focus_toggle,toggle,toggle_open_no_focus,ensure_visible,get_active_terminal_bufnr, andsend_to_terminal(text, { submit = true, focus = false })[terminal.lua:M.send_to_terminal]terminal.luatest hook:_get_managed_terminal_for_test()returns the provider's_get_terminal_for_test()result, or nil [terminal.lua:M._get_managed_terminal_for_test]terminal.luadefaults:split_side = "right",split_width_percentage = 0.30,provider = "auto",auto_close = true,auto_insert = true,fix_streamed_paste = "auto", andgit_repo_cwd = false[terminal.lua:M.defaults]diff.lua:setup,open_diff(old_file_path, new_file_path, new_file_contents, tab_name)(wraps_open_native_diff, a legacy path that nothing in the plugin calls. It never checks for a user's diff. It opens the old file in the main editor window, current tab first, and runs:diffthisthere, so it joins a vimdiff in that tab. With no main editor window, it uses the window right of the top-left one, which can be one of the user's diff windows),open_diff_blocking(old_file_path, new_file_path, new_file_contents, tab_name, client_id),close_diff_by_tab_name,close_all_diffs,close_pending_diffs,close_rejected_diffs,close_saved_diffs,close_diffs_for_client,accept_current_diff,deny_current_diff, andreload_file_buffers_manual[diff.lua:M.open_diff_blocking]diff_inline.lua:setup_inline_diff,compute_inline_diff,extract_new_content,render_diff_buffer,resolve_inline_as_saved,resolve_inline_as_rejected, andcleanup_inline_diff[diff_inline.lua:M.setup_inline_diff]selection.lua:enable(server, visual_demotion_delay_ms),disable,get_latest_selection,get_visual_selection,get_cursor_position(winid?),is_claude_terminal_buffer,send_current_selection,get_range_selection,send_at_mention_for_visual_selection, and the autocmd handlerson_cursor_moved,on_mode_changed, andon_text_changed[selection.lua:M.enable]selection.luainternals:state,debounce_update,update_selection,handle_selection_demotion,get_visual_selection_from_marks,flush_visual_selection,has_selection_changed, andsend_selection_update[selection.lua:M.update_selection]lockfile.lua:lock_dir,generate_auth_token,create(port, auth_token?) -> ok, path, token,remove,get_auth_token, andget_workspace_folders[lockfile.lua:M.create]integrations.lua:get_selected_files_from_tree()dispatches on filetype to_get_nvim_tree_selection,_get_neotree_selection,_get_oil_selection,_get_mini_files_selection,_get_mini_files_selection_with_range(start_line, end_line),_get_netrw_selection, and_get_snacks_picker_selection[integrations.lua:M.get_selected_files_from_tree]visual_commands.lua:create_visual_command_wrapper,exit_visual_and_schedule,validate_visual_mode,get_visual_range,get_tree_state,capture_visual_selection_data, andget_files_from_visual_selection[visual_commands.lua:M.create_visual_command_wrapper]cwd.lua:resolve(term_cfg, ctx)andgit_root(start_dir)[cwd.lua:M.resolve]utils.lua:shell_split,parse_command,expand_tilde,normalize_focus,selection_type,utf8_sequence_length,valid_utf8, andwin_set_buf_without_bufenter[utils.lua:M.parse_command] [utils.lua:M.valid_utf8] [utils.lua:M.win_set_buf_without_bufenter]logger.lua:levels,setup,error,warn,info,one_line,debug,trace, andis_level_enabled[logger.lua:M.setup]health.lua:check()[health.lua:M.check]
setuprunsconfig.applybefore logger, terminal, and diff setup. The auto-start runs only afterdiff.setup[init.lua:M.setup]startrefuses with "Already running". The auth token must be a string of length 10 or more. Ifserver.startfails,startreturns the error at once. If the lock file fails,startstops the server and clears its state [init.lua:M.start]startrequires the token returned bylockfile.createto equal the generated token. Otherwise it removes the lock file, stops the server, and clears its state. Whenlockfile.removereturns an error, it only logs that error as a warning and still returns the mismatch error [init.lua:M.start]stopreturns "Not running" without a server. Whenlockfile.removereturns an error,stoponly logs that error as a warning.lockfile.removereturns that error whenos.removefails, also when it fails without raising (for example in a read-only directory), so the lock file stays and the failure is logged [init.lua:M.stop] [lockfile.lua:M.remove]- The auth token is 32 lowercase hex characters from
vim.loop.randomor/dev/urandom. It never comes frommath.random[lockfile.lua:get_random_bytes] - The lock directory is
$CLAUDE_CONFIG_DIR/idewhen that variable is set, else~/.claude/ide. It is resolved once when the module loads [lockfile.lua:M.lock_dir] lockfile.createrequires a port in 1..65535 and a caller token of 10..500 characters. It writes JSON{ pid, workspaceFolders, ideName = "Neovim", transport = "ws", authToken }[lockfile.lua:M.create]
config.validateasserts most top-level fields. It does not assertdisable_broadcast_debouncingorenable_broadcast_debouncing_in_tests. Examples:0 < port_range.min <= max <= 65535,log_levelintrace|debug|info|warn|error,envvalues are strings,modelsis a non-empty list of{ name, value }, anddiff_opts.layoutisvertical|horizontal|unified. Forterminalit asserts only that it is a table and checksprovider_opts. It does not assertterminal.provideror otherterminal.*keys [config.lua:M.validate]config.applydeep-copies the defaults. It maps the legacyvertical_splitoption tolayout, and legacyopen_in_current_tabtoopen_in_new_tab[config.lua:M.apply]- Provider selection:
autouses snacks if available, else native.externalrequiresexternal_terminal_cmdas a function or as a string containing%s. Every other failure falls back tonative[terminal.lua:get_provider] - The Claude command is
terminal_cmd, orclaudewhen it is unset.no_proxyandNO_PROXYalways includelocalhost,127.0.0.1, and::1[terminal.lua:get_claude_command_and_env] - Terminal cwd precedence:
cwd_provider, then staticcwd, then the git root whengit_repo_cwdis set [terminal.lua:build_config] send_to_terminalnever opens a terminal. It refuses thenoneandexternalproviders. It wraps multi-line text in bracketed-paste markers [terminal.lua:M.send_to_terminal]utils.parse_commanddoes no globbing or variable expansion, so model aliases such asopus[1m]stay intact [utils.lua:M.parse_command]
- Line numbers sent to Claude are 0-indexed.
:ClaudeCodeAddconverts the 1-indexed input [init.lua:M._create_commands] - A mention sent while disconnected goes to the queue with the same absolute path as a connected send, and the terminal opens. A connected send goes to the queue too, unless broadcast debouncing is off and the queue is empty [init.lua:M._broadcast_at_mention]
- The queue sends each mention once, oldest first: 50 ms after a send,
connection_wait_delayms after a client completed its handshake, and 25 ms apart. A mention leaves the queue only when a connected client took it. When the write of that frame then fails on every client, the error "Claude's connection failed while the @ mention was sent" appears, and the mention is not sent again. When Claude is not connected, the mentions wait up toconnection_timeoutms for a handshake, then the queue is cleared with the error "Connection timeout - clearing N queued @ mentions". A handshake stops that timeout, and a flush that finds no connected client starts it again.queue_timeoutis accepted but unused [init.lua:schedule_mention_flush] M.stopclears the queue and stops its timers. A queue timer's callback does nothing once its timer was stopped or replaced, so nothing queued before a stop reaches Claude [init.lua:start_timer]- Selection tracking is a no-op unless it is enabled. Updates are debounced by 100 ms, except that leaving visual mode, editing or consuming the held text and moving the cursor away from a held selection report at once [selection.lua:M.flush_visual_selection] [selection.lua:reconcile_held_selection]. The held text can also change while you are in the Claude terminal (a reload, a plugin's edit), where
TextChangeddoes not fire for its buffer: a listener fromnvim_buf_attachon that buffer sends the file's cursor then [selection.lua:watch_selection_buffer]. Entering another file waits for the debounce. Claude terminal buffers are skipped [selection.lua:M.update_selection] - The automatic paths send
selection_changedonly when the selection differs from the last one sent [selection.lua:M.update_selection] [selection.lua:M.flush_visual_selection].send_current_selectionsends with no change check [selection.lua:M.send_current_selection] - Select mode (
gh, orv,Vand CTRL-V when'selectmode'containscmd) is tracked like Visual mode [selection.lua:is_visual_mode], and a command such as a<cmd>ClaudeCodeSend<CR>mapping takes the visual path there [visual_commands.lua:M.create_visual_command_wrapper]. The visual path ends Select mode. For a mapping made withmode = "v"(:vmap), Neovim queues a key that restores Select mode after the mapping, and the visual path cancels it withgV[visual_commands.lua:M.exit_visual_and_schedule] - A visual selection is flushed on visual exit. The flush is skipped if the buffer changed during visual mode [selection.lua:M.flush_visual_selection]
selection_changedpositions are 0-based lines and 0-based UTF-8 byte offsets into the buffer line, not VS Code's UTF-16 code units.endis exclusive. A position inside a multibyte code point, such as a stale mark after an edit andgv, moves back to the code point's first byte [selection.lua:code_point_start]. A charwise or blockwise selection ends after the character that starts at its end position. That character is whatytakes as one (vim.fn.byteidx): it includes its composing characters, and on Neovim 0.11+ the rest of its grapheme cluster. On Neovim 0.11+ a control, such as a TAB, U+0085 or U+200B, is a character of its own, without the composing characters after it. So is an invalid byte on every version, although before 0.11 the cursor can skip the composing characters after it. With'selection'set toexclusive, a selection of more than one position ends after the character before that position instead. Before 0.11,ycan leave out composing characters that follow an invalid byte there, and the selection keeps them [selection.lua:selection_end_byte]- A charwise selection that includes a line break (
v$) ends at the start of the next line, and its text ends with that line break, as Neovim yanks it. On the buffer's last line, for a blockwise selection, and with'virtualedit'containingall(where the end is a virtual space), the end stays at the end of the line. On an empty line, and for a block whose last row is empty, that end ischaracter1, because Claude Code counts an end atcharacter0 as the line before. Withall, the text has none of the virtual spaces that Neovim'syadds. A blockwise selection sends the contiguous span between its first and last positions, not the rectangle [selection.lua:end_past_line] - A linewise selection ends on its last line at that line's byte length. When that line is empty and another line follows, it ends at the start of the next line, and its text ends with the empty line's line break, because Claude Code counts an end at
character0 as the line before. An empty last line of the buffer ends atcharacter1 [selection.lua:extract_linewise_text] - A mention sent without a range from a tracked selection that ends at
character0 of a line ends on the line before [selection.lua:M.send_at_mention_for_visual_selection] - Selection text sent to Claude is always valid UTF-8. Each invalid byte sequence in the buffer becomes U+FFFD, so the text can be longer than the byte range of its positions [utils.lua:M.valid_utf8]
open_diff_blockingmust run inside a non-main coroutine. Otherwise it raises-32000[diff.lua:M.open_diff_blocking]open_diff_blockingmakesold_file_pathabsolute against the cwd at the call, so a later:cddoes not change the file a diff points at [diff.lua:M.open_diff_blocking]- A diff opens in a new tab when
diff_opts.open_in_new_tabis set, or when the current tab or the tab of the window it would use already has a diff window.close_tabthen goes back to the tab the user was in. Otherwise it starts from a window that shows the old file or from the main editor window, neither of which is a diff window, the preview window, or a floating window. When neither exists, it splits the current window [diff.lua:M._setup_blocking_diff] [diff.lua:find_main_editor_window] - The current tab comes first, as VS Code opens a diff in the active editor group: the current tab's window that shows the old file, then the current tab's main editor window, then the same two in the other tabs. A
unifieddiff skips the window that shows the old file and opens next to the main editor window, current tab first. It is not a Neovim diff, so a user's diff in the current tab does not give it a new tab. Only when the current tab has neither does the diff go to another tab, and Neovim then switches to that tab. For example, with only the preview window in the current tab and the Claude terminal next to an empty window in another tab, the diff opens in that other tab. Averticalorhorizontaldiff of an existing file that starts from a window with an empty, unnamed buffer opens the file there, and that window keeps showing the file afterclose_tab[diff.lua:windows_current_tab_first] [diff.lua:choose_original_window] - A diff in a new tab shows the Claude terminal in a split of that tab, then puts the cursor in the proposal, in Normal mode. The terminal buffer goes into the split with
BufEnterin'eventignore'(utils.win_set_buf_without_bufenter), so an auto-insert handler of that buffer, such as the Snacksauto_insertautocmd or the diff's ownBufEnterhandler from an earlier diff, cannot queue astartinsertthat would start in the proposal. Entering that terminal window later still starts terminal mode, because withterminal.auto_inserton the diff gives the terminal buffer aBufEnterhandler for it. AWinClosedautocmd for that window deletes the handler when that window closes. That happens whenclose_tabor the user closes the diff tab, and also when the user closes that split alone (:q) while the diff is still open. Until then the handler stays, even if that window now shows another buffer, and it starts terminal mode on every entry of the terminal buffer, in any window. After the window closes, a later entry of the terminal buffer, such as a restored float, queues no insert.diff_opts.keep_terminal_focus = truestill moves the cursor to the terminal [diff.lua:display_terminal_in_new_tab] [utils.lua:M.win_set_buf_without_bufenter] - A new diff with an existing tab name first rejects the old diff (if pending), then cleans it up [diff.lua:M.open_diff_blocking]
- A diff that resolves before
open_diff_blockingyields, for example from aClaudeCodeDiffOpenedhandler that runs:ClaudeCodeDiffDeny, is returned at once as the openDiff reply instead of a deferred one [diff.lua:M.open_diff_blocking] - A diff never splits the preview window while the Claude terminal is a tiled window in the same tab. It splits the terminal's window instead, because a split of the preview window keeps its
'previewheight'lines. Averticalorunifieddiff then opens right of the terminal, so with the preview window left of the terminal, the terminal sits between the preview window and the diff. Ahorizontaldiff opens below the terminal. With a floating or hidden Claude terminal, a diff in a tab with only the preview window splits the preview window. The legacyopen_diffnever opens the old file in the preview window either [diff.lua:leave_preview_window] [diff_inline.lua:create_fallback_editor_window] [diff.lua:M._open_native_diff] - A
unifieddiff keeps the Claude terminal's width, with two exceptions: the terminal in a new tab, and a terminal whose window it splits (the old width includes the columns the diff took). It sizes those as the other layouts do:terminal.diff_split_width_percentageof the columns, elsesplit_width_percentage, and not at all withdiff_opts.auto_resize_terminal = false[diff_inline.lua:M.setup_inline_diff] [diff.lua:resize_terminal_for_diff] - Closing a diff gives the windows of the tab it split back the sizes they had before the diff, because the diff's
wincmd =and the closing of its windows resize them. When two diffs split one tab, the sizes are those from before the first, and the last diff to close gives them back. Nothing is resized when the tab's window layout, or the space its windows fill (screen size, tab line), changed during the diff [diff.lua:restore_window_sizes] - The restored sizes include a Claude terminal in that tab, so a width the user gave it stays. A diff never resizes a Claude terminal outside its own tab, so that one keeps its width too. Only when the sizes cannot come back does a
verticalorhorizontaldiff set the Claude terminal, in any tab, tosplit_width_percentageof the columns, and not at all withdiff_opts.auto_resize_terminal = false. A diff in its own tab sets no width when it closes: its terminal window closes with its tab, and a Claude terminal the close has to show again opens atsplit_width_percentage[diff.lua:M._cleanup_diff_state] - When closing a diff closes the window the cursor is in, the cursor goes back to the window it was in when the diff opened, in another tab too. A cursor the user moved to another window during the diff stays there. A diff in its own tab goes back to the tab the user was in instead. With
diff_opts.keep_terminal_focus = truethe cursor is in the Claude terminal during the diff, so it stays there. When no Claude terminal is shown, the cursor stays in the diff, so it goes back to the window it was in, as without the option [diff.lua:restore_cursor] - When the target buffer is dirty, diff setup raises
-32000with message "Diff setup failed". Itsdataholds the inner error, which contains "Cannot create diff: file has unsaved changes" [diff.lua:M._setup_blocking_diff] - A diff resolves only from status
pending. Save returnsFILE_SAVEDand the content. Reject returnsDIFF_REJECTEDand the tab name [diff.lua:M._resolve_diff_as_saved] - A write of the proposed buffer (
:w,:wq,:x,ZZ, which runBufWriteCmd) never writes the file. It accepts a pending diff and does nothing else.:wleaves the windows open untilclose_tab, and:wq,:xandZZalso close the proposal window. A write after the diff has its answer sends nothing and does not fail (the handler never returnstrue, which would delete the autocmd and make the next:wfail with E676). It warns when the buffer holds edits that Claude never gets [diff.lua:M._write_proposal] - A pending diff rejects at once when its proposed buffer is shown in no window, in the split and the unified layout: its last window closes (
:q,:close,<C-w>c,:tabclose), or that window switches to another buffer (:edit,:buffer,:enew, which only hide it). A copy of the proposal in another window or tab defers the reject until that copy leaves too. The switch path rejects on the next loop turn, and only when the diff is still the same one and still hidden. Both layouts keep the proposed buffer's'bufhidden'athide: the buffer is modified, andwipewould make:qfail with E37 [diff.lua:M._register_reject_when_hidden] [diff_inline.lua:M.setup_inline_diff] - A diff that has its answer, by either one, clears
'modified'on its proposed buffer. While the flag stayed set,:wqleft the window open, and:qastopped with E37 or E162 and moved a window onto the hidden buffer untilclose_tabdeleted it [diff.lua:mark_proposal_unmodified] - The cleanup of a diff closes the proposal window, and the window that the split layout opened for the file's old text, only while each still shows its buffer. A window that the user switched to another buffer stays with that buffer.
:editturns diff mode off by itself, except when the new buffer is in diff mode in another window, as the file's own buffer is. So the split layout's cleanup ends diff mode only in a kept window that shows the file's own buffer, and leaves a diff mode in a window that shows another buffer, which is the user's. One limit comes from Neovim: the defaultcloseoffin'diffopt'turns diff mode off in the last window that has it when another diff window closes. That ends a user's diff mode in a kept original-side window when the proposal window closes, and in a kept proposal window when the cleanup closes the window that the plugin opened for the file. A user's diff mode over two or more windows stays. The unified layout needs no cleanup there: it sets'signcolumn'toyeswith local scope, for the proposal buffer only, so the window shows the sign column it had before the diff once the user switches it, and keeps a value the user sets afterwards. Two cases still close the window: a diff in its own tab (open_in_new_tab, or a tab that already has a diff) closes the whole tab, and the fallback window that the plugin opens in a tab that holds only the Claude terminal always closes [diff.lua:M._close_proposal_window] [diff.lua:M._cleanup_diff_state] [diff_inline.lua:M.cleanup_inline_diff] [diff_inline.lua:M.setup_inline_diff] close_pending_diffsandclose_diffs_for_clientclose onlypendingdiffs.close_rejected_diffscloses onlyrejecteddiffs.close_all_diffscloses every diff [diff.lua:M.close_pending_diffs]- A bulk close lists its diffs first, and each close fires
ClaudeCodeDiffClosed. Before each close it reads the entry again: a diff a handler accepted since the list was made stays open, and one a handler rejected closes as listed. It closes the newest diff first, not inpairs()order, so when each close puts the cursor back, the cursor ends where it was before the oldest of them opened.:ClaudeCodeCloseAllDiffsruns three bulk closes, for pending, then rejected, then accepted diffs, so it closes newest first only within each status. With diffs of more than one status, the cursor can end in another window [diff.lua:close_active_diffs] :ClaudeCodeCloseAllDiffsrunsclose_pending_diffs,close_rejected_diffs, thenclose_saved_diffs[init.lua:M._create_commands]close_saved_diffscloses asaveddiff only once its file or the file's buffer holds the accepted text. When the file does not hold it, and the file's buffer has no unsaved changes and an empty'buftype', it first loads the file into its buffer and sets the accepted lines there, unsaved. It never writes the file. The lines follow the buffer's format: in adosbuffer it drops a CR before each line break, so:wwrites every line break as CRLF, LF-only lines of the proposal included. In a'bomb'buffer it drops a leading BOM. A text without a final line break turns'fixeol'off until the buffer's next write or read. Once the lines are set, it makes the buffer listed and durable: a'bufhidden'ofwipe,delete, orunloadbecomeshide. When loading the file or setting the lines fails, it wipes a buffer it created, or unloads one it loaded. It returns the count, each file whose buffer got the text with the command that writes it (:w,:checktime, then :w!for a'readonly'buffer (the!also skips the changed-since-reading check, so:checktimeruns first), or when the file's folder does not exist:w ++p, which Neovim 0.8 lacks, so there:call mkdir(expand('%:p:h'), 'p') | w) and a'fileencoding'that cannot hold the text, and each saved diff it kept with the reason (forE325, the next step: open the file with:split <file>, answering the swap file prompt if Neovim shows one) [diff.lua:M.close_saved_diffs]active_diffsis module state. A reload that clearspackage.loaded["claudecode.diff"]forgets the open diffs, and the new module'sClaudeCodeMCPDiffaugroup (created withclear = true) deletes their autocmds. Their windows and proposal buffers stay, so an accepted diff's text is then only in its proposal buffer [diff.lua:get_autocmd_group]
:ClaudeCodeCloseAllDiffscan load an accepted diff's file into a listed buffer and replace its lines with the accepted text, leaving the buffer modified. For a text without a final line break it adds a buffer-localBufWritePost/BufReadPostautocmd that restores'fixeol'once [diff.lua:M.close_saved_diffs]startbinds a127.0.0.1TCP port and writes the lock file. It enables selection tracking only whentrack_selectionis true.stopremoves the lock file, or logs a warning when it cannot, and stops the server [init.lua:M.start]lockfile.createcreates the lock directory with mode 0700. It then tries to chmod an existing directory to 0700 throughpcalland ignores the result, so on platforms withoutfs_chmodthe old mode stays. It writes a temp file openedwxwith mode 0600, then renames it to<port>.lock[lockfile.lua:M.create]- The terminal env gets
ENABLE_IDE_INTEGRATION=true,FORCE_CODE_TERMINAL=true,CLAUDE_CODE_SSE_PORT=<port>only while the server runs, the userenv(merged last, so it can override these), and the mergedno_proxy/NO_PROXY[terminal.lua:get_claude_command_and_env] terminal.setupcan replace the globalvim.pastethroughpaste_fix.apply[terminal.lua:M.setup]- The plugin broadcasts
at_mentioned(filePath,lineStart,lineEnd) [init.lua:M._broadcast_at_mention] andselection_changed[selection.lua:M.send_selection_update] to every connected client - While Claude holds a visual selection,
selection.luaattaches annvim_buf_attachlistener (on_lines,on_reload,on_detach) to its buffer, once per buffer. It detaches itself at the nexton_linesafter Claude holds no selection of that buffer, and a reload with:editor:edit!detaches it, so it attaches again [selection.lua:watch_selection_buffer] - Augroups:
ClaudeCodeShutdown, which holds theVimLeavePreautocmd that stops on exit [init.lua:M.setup],ClaudeCodeSelection[selection.lua:M._create_autocommands],ClaudeCodeMCPDiff[diff.lua:get_autocmd_group],ClaudeCodeDiffCleanup[diff.lua:M._open_native_diff], and a module-levelVimLeavePreautocmd indiff.luathat callsM._cleanup_all_active_diffs("shutdown")[diff.lua:M._cleanup_all_active_diffs] - Diffs create scratch buffers, splits, and tabs. They resize the terminal window and fire
Userevents. Cleanup closes these and reloads unchanged file buffers 100 ms after a save [diff.lua:M.close_diff_by_tab_name] - The native diff writes each temp file into its own session folder under
stdpath("cache")/claudecode_diffs, orclaudecode_diffs_fallbackwhen it cannot create a directory in the first. The folder name includes the Neovim PID, so two running Neovim processes never share one. If both fail,open_diffreturnssuccess = falsewith an error that names both directories [diff.lua:create_temp_file]. ABufDelete/BufWipeoutautocmd on the proposed buffer cleans up once, even when a wipe fires both events. It deletes the file and then its empty folder withvim.fn.delete, and warns once for each delete that fails. When the file delete fails, the folder is not empty, so its delete fails too and both warnings show [diff.lua:cleanup_temp_file]._cleanup_diff_statedoes not [diff.lua:M._open_native_diff] diff_inline.luacreates theclaudecode_inline_diffnamespace when the module loads. It defines the fourClaudeCodeInlineDiff*highlight groups (withdefault = true) [diff_inline.lua:setup_highlights] and sets extmarks [diff_inline.lua:M.render_diff_buffer]- The plugin runs
git rev-parse --show-toplevelfor the git root [cwd.lua:M.git_root], andhealth.checkruns<claude> --version[health.lua:check_cli] loggerdefers all output throughvim.schedule. ERROR and WARN go tovim.notify. Other levels go tonvim_echo[logger.lua:log]- Below log_level debug,
one_linereplaces each run of whitespace with one space. A line wider thanv:echospacecells drops its component tag and putsERROR:orWARN:in place of its level tag (an info line gets none), so the level shows without colors. It drops the[ClaudeCode]name too when that would take half the width. Then it is cut to the width, ending in...whenv:echospaceis at least 3. Below debug, only that line reaches:messages: Neovim adds a line to the message history only by showing it, and showing a line wider than the command line stops at "Press ENTER", so the whole line shows only at log_level debug [logger.lua:M.one_line] M.startlogs the reason a server did not start throughone_line, for exampleNo free port in 10000-65535 (EADDRINUSE), becauseauto_startruns it while Neovim starts. That line fits at 80 columns.M.startreturnsServer not started: <reason>[init.lua:M.start]
- Run:
mise run test - Targeted:
busted -v tests/unit/init_spec.lua,busted -v tests/unit/config_spec.lua,busted -v tests/unit/diff_spec.lua,busted -v tests/unit/diff_inline_spec.lua,busted -v tests/unit/terminal_spec.lua,busted -v tests/unit/utils_spec.lua,busted -v tests/unit/health_spec.lua,busted -v tests/unit/logger_spec.lua,busted -v tests/integration/basic_spec.lua