This post is in relation to my [[latios|latios.nvim] project, which can be found on GitHub.
At the time of writing this post I’ve built a naive AI copilot as a plugin for neovim using lua. I say naive because it…
- Is slow
- Doesn’t really have the best completions
- Isn’t super easily hackable
- Not rigorously tested for backwards compatibility
But what we do have is a copilot that
- shows inline completions that can be
Tab
completed - builds a context using treesitter, lsp, and several other sources
- requests completions from claude sonnet 3.5
A decent starting point. My process for building this was to analyze a few other existing copilot solutions in neovim using repo-to-prompt and claude. The artifacts feature had just released when I started development and a lot of those were incorporated into the initial designs.
Note
If you want to follow along in my repo I’m writing this at the point of commit 199d4ac
A lot of the code is redundant and definitely needs some cleaning up before it’s presentable, but I’ll do my best to only cover the parts that are actually used. Some aspects of the code are related to neovim plugins in general while others are focused on the mechanics of a copilot. I’ll make an effort to delineate between the two in my description
Structure
Neovim Plugin Structure
The structure of the repo itself is very similar to how today.nvim is structured.
latios/
├─ doc/
│ ├─ latios.txt
├─ lua/latios/
│ ├─ init.lua
│ ├─ ...
│ ├─ ...
├─ plugin/
│ ├─ latios.lua
├─ README.md
The doc
folder contains the documentation that will be displayed when running
help on a plugin in neovim. The latios.txt
file has some really bare bones
content right now.
The plugin
folder itself just has a single file that is used to load the lua
module and run some simple setup code.
We use a global variable to determine if the plugin is enabled at all. If it is,
then we register a user command to take in other inputs. This makes it so users can
manually toggle things while in a neovim session using syntax like Latios <cmd>
. There are a few commands there right now just for toggling the
functionality.
Finally, we load the lua/
modules and run the entrypoint setup
function.
This structure is so we can take advantage of
lazy-loading.
Only the setup code is run when a neovim buffer is opened. The rest of the
functionality of the plugin doesn’t load until you run something that will cause
a require()
call.
Everything in the lua/
folder is requirable by the latio.lua
file so by
simply requiring the name of the plugin it will load the entrypoint init.lua
file and then run the setup()
function within.
Co-pilot Structure
The copilot’s logic is all contained within the lua/latios
folder. Zooming in
on this folder we have
latios/
├─ plugin/latios/
│ ├─ context.lua
│ ├─ init.lua
│ ├─ server.lua
│ ├─ display.lua
There are some other files in the codebase, but as mentioned earlier they are not used and contain some redundant code that was generated by claude.
To highlight how the functionality is split between these modules
init.lua
sets up the autocommands that will trigger LLM completionscontext.lua
manages deriving the context to send to an LLM so it knows what to generateserver.lua
manages sending the context to the LLM to get the completiondisplay.lua
manages displaying the potential completion to the screen and writing it to the buffer if accepted
Zooming in even more on each of these modules…
init.lua
The main content of this file is the setup
function that was called from
plugin/latios.lua
. It first loads configuration variables that would be setup
via a package manager such as Lazy.nvim.
Then it creates 2 autocommands.
The first one will trigger a new completion when the user enters insert mode, the user moves the cursor in insert mode, and when the completion options in a completion plugin like nvim-cmp. If a completion request yields a result, then the plugin will display it inline in the buffer.
The second one will clear the display and cancel any in-flight requests when the user exits insert mode. This is to prevent any excess requests and to remove stale completion results from the screen.
context.lua
There are a few different functions in this module that work to build context from multiple different sources within the editor. I’ll cover the main two.
get_treesitter_context
gets the node the cursor is at and returns the tree up to
that point.
get_lsp_context
which uses the built in lsp to get any diagnostics, the
symbol under the cursor, and the function signature if the cursor is within a
function.
The remaining functions will use those two sources along with the contents of the buffer and the filetype to build a table that is used as the context. There is also a reduced context function that attempts to truncate the lines of the buffer that are returned and another function to cache the results. Both of these latter functions were some early attempts for optimizing performance, but are largely un-tested.
server.lua
The two main functions in this module are construct_prompt
which takes the
context and makes a proper prompt for the language model and
request_anthropic_completion
which uses that prompt and sends a request to the
Anthropic API for a completion
First construct_prompt
The function essentially takes the table returned from context.lua
and tries
to put it more into prose for the language model. It also structures it as a
set of messages so it matches the chat model API specs.
Next in request_anthropic_completion
This function launches an http request using curl
and then has some callback
functions depending on the output. The request is initiated via a jobstart
command. This is to manage some asynchronous behavior and make it easier to
cancel requests. The rest of the file is related to functions helping to manage
the asynchronous nature of the request.
display.lua
This is the last big module in the codebase. The main purpose is to be able to display inline completions the same way apps like GitHub Copilot or Cursor, rather than just using the completions popup.
This is achieved by using neovim’s Extended
Marks feature. The 3 main
functions are used to show, clear, and accept completions returned by
server.lua
.
The function splits the completion into lines and displays them using the same
Highlight
as comments. In the future this may be a configurable feature.
This will simply delete the extended mark associated with the completion.
current_extmark_id
is a global variable the module uses to track the displayed
extended marks.
This function will take the extended mark text and insert it into the buffer. It will replace the current line, append any additional lines, and move the cursor to the end of the completion. Finally, it’ll remove the extended mark
Challenges and Gotchas
While making this initial MVP there were definitely a couple of challenges I faced along the way. Many of these were related to performance.
Debouncing Completion Requests
As you probably guessed from the fact that a completion is triggered everytime
the cursor moves, there are a lot of requests that can occur. That’s why the
completion request to server.lua
is debounced. There is a short delay before
the request actually starts to account for the fact that the cursor will move
often while in insert mode. Each time the completion method is called the delay
will restart.
Developer Experience
Developing the plugin itself was a bit tricky and cumbersome. I loaded the local
plugin using lazy.nvim
, but everytime I made a change I had to exit neovim and
re-enter the buffer. I will probably invest some more time in just improving the
workflow there.
Debugging and Profiling
Similarly, it was hard to debug and profile the plugin. It wasn’t very clear how
to print error messages and do a normal print
debugging strategy. A lot of the
sources I found just recommended printing to a buffer or file, however I ran
into some issues while doing that of the copilot being unable to parse the
treesitter grammar in the buffer or read it’s filetype. I quickly fixed those,
but the workflow definitely still needs some work.
Profiling was also tricky while I was trying to investigate some of the
performance issues. To address both of these issues I made a naive debug.lua
and profiler.lua
modules that probably need some work.
Sources
- https://m4xshen.dev/posts/develop-a-neovim-plugin-in-lua
- https://www.linode.com/docs/guides/write-a-neovim-plugin-with-lua/
- https://www.reddit.com/r/neovim/comments/15f78jq/plugin_development_starting_point/
- https://zignar.net/2022/11/06/structuring-neovim-lua-plugins/
- https://miguelcrespo.co/posts/how-to-write-a-neovim-plugin-in-lua/
Next Steps
After reflecting on my development up to this point and writing out my process I
can already see a lot of places for improvement. In a lot of my modules I’m
calling require
at the top instead of locally in functions, while pulling the
docs for jobstart
I saw it recommended using vim.system
instead, a lot
of my context.lua
probably doesn’t have the most relevant information, and I
can probably improve the LLM’s prompt by switching the last message to be a
assistant message with the current code line so that the LLM attempts to
complete it.
I know a lot of work has also been done in this space in the time I’ve been working on it and I want to keep researching the best methods and approaches to see what I can learn.
- Research more methods for building a more streamlined context for the copilot
- Finding better debugging and testing workflows for neovim plugin development
- Looking into a chat window and a select to edit feature
- Improve the performance of the plugin