A curiosity journal of math, physics, programming, astronomy, and more.

Asynchronous split in Neovim

I've been using Simon Willison's llm Python package to run local and cloud LLMs from the command line, especially for brief explanations of code (usually Bash) or simple refactoring. My workflow was to visually select some code in Neovim, copy it to my clipboard with "*y, then switch to a Tmux pane with an empty shell prompt and pipe pbpaste into llm -t [template] "[prompt]". That worked, but to do the same more easily and without leaving Neovim, I worked out the following Fennel function. It opens a new buffer in a vertical split, sets the buffer's filetype, then runs a shell command asynchronously with some stdin, and spits the command's stdout into new buffer:

(fn async-shell [cmd input filetype]
  (let [buf (vim.api.nvim_create_buf false true)]
    (vim.cmd "vertical new")
    (vim.api.nvim_win_set_buf 0 buf)
    (doto buf
      (vim.api.nvim_buf_set_option :buftype :nofile)
      (vim.api.nvim_buf_set_option :bufhidden :wipe)
      (vim.api.nvim_buf_set_option :filetype filetype))
    (let [on-stdout
          (fn [_ data _]
            (when data
              (if (= [""] data)
                (vim.api.nvim_buf_set_lines buf -1 -1 false [""])
                (let [[part & parts] data
                      line-count (vim.api.nvim_buf_line_count buf)
                      [last-line] (vim.api.nvim_buf_get_lines
                                    buf (- line-count 1) line-count false)
                      new-last-line (.. (or last-line "") part)]
                 (vim.api.nvim_buf_set_lines
                   buf (- line-count 1) line-count false [new-last-line])
                 (when (< 0 (length parts))
                   (vim.api.nvim_buf_set_lines buf -1 -1 false parts))))
              (let [win (vim.fn.bufwinid buf)]
                (when (not (= -1 win))
                  (vim.api.nvim_win_set_cursor
                    win [(vim.api.nvim_buf_line_count buf) 0])))))
          on-exit
          (fn [_ exit-code _]
            (let [exit-message (if (= 0 exit-code)
                                 "Process completed successfully"
                                 (.. "Process exited with code " exit-code))]
              (vim.api.nvim_buf_set_lines buf -1 -1 false ["" exit-message])))
          job-id
          (vim.fn.jobstart cmd
            {:stdin :pipe
             :stdout_buffered false
             :stderr_buffered false
             :on_stdout on-stdout
             :on_exit on-exit})]
      (if (< 0 job-id)
        (do
          (vim.fn.chansend job-id input)
          (vim.fn.chanclose job-id :stdin))
        (vim.api.nvim_buf_set_lines buf 0 -1 false ["Failed to start command"])))))

The on-stdout callback is somewhat complicated, due to having to handle llm emitting chunks of text rather than printing output line by line. Based on inspecting data, it's typically only one or two values. When there's only one chunk and it's an empty string, a blank line should be added to the end of the buffer. Otherwise, the first string in data should be added to the end of the last line in the buffer, and the other string should be added on a new line. Much of the logic in the function is just to get the last line in the buffer and append to it.

When using this function with the llm command, the filetype is "markdown". If the command is invoked while in visual mode, input is the current visual selection.

If you have suggestions for improvements, feel free to email me.