How Request Status Tracking Works Internally in ThePrimeagen's 99 Plugin
ThePrimeagen's 99 plugin implements request status tracking through the RequestStatus class in lua/99/ops/request_status.lua, which uses a self-rescheduling vim.defer_fn loop to update a Braille-character spinner, manage a rolling buffer of status messages, and render output either as Neovim virtual text via marks or through a user-provided callback.
The 99 plugin by ThePrimeagen provides live feedback during asynchronous operations like LSP requests. At the heart of this functionality lies a sophisticated request status tracking system that combines visual spinners, message buffering, and Neovim's virtual text API. Understanding this implementation reveals how to build non-blocking status indicators in Lua-based Neovim plugins.
Core Architecture of the RequestStatus Class
The RequestStatus class defined in lua/99/ops/request_status.lua serves as the central coordinator for all status tracking operations. It aggregates three distinct subsystems to create a cohesive user experience.
The Three Core Components
Every RequestStatus instance combines these pieces:
StatusLine– A spinning Braille-character indicator that advances on each tick to provide visual feedback that work is in progress.linesbuffer – A Lua table holding the most recent status messages, capped by themax_linesparameter to prevent unbounded growth.markorcallback– Either aMarkobject fromlua/99/ops/marks.luathat updates Neovim virtual text, or a user-provided function that receives the status lines array.
Constructor: Initializing the Tracker
The RequestStatus.new() function (lines 46-58 of request_status.lua) sets up the initial state:
function RequestStatus.new(update_time, max_lines, title_line, mark_or_fn)
local self = setmetatable({}, RequestStatus)
self.update_time = update_time -- ms between spinner updates
self.max_lines = max_lines
self.status_line = StatusLine.new(title_line) -- spinner + title
self.lines = {}
self.running = false
if type(mark_or_fn) == "function" then
self.callback = mark_or_fn -- user-supplied fn(status_lines)
else
self.mark = mark_or_fn -- a 99.ops.marks.Mark object
end
return self
end
The constructor distinguishes between callback-based and mark-based rendering by checking the type of the fourth argument.
The Status Update Loop
The heart of the request status tracking system is the self-rescheduling timer loop that updates the visual state without blocking Neovim's main thread.
Starting the Spinner with vim.defer_fn
The RequestStatus:start() method (lines 78-96) initiates the update cycle:
function RequestStatus:start()
local function update_spinner()
if not self.running then return end
self.status_line:update() -- advance spinner index
if self.mark then
self.mark:set_virtual_text(self:get()) -- update virtual text
end
if self.callback then
self.callback(self:get()) -- invoke user callback
end
vim.defer_fn(update_spinner, self.update_time) -- schedule next tick
end
self.running = true
vim.defer_fn(update_spinner, self.update_time) -- first tick
end
This implementation uses vim.defer_fn to schedule the next iteration, creating a non-blocking loop that terminates automatically when self.running becomes false.
Rendering the Status Output
The RequestStatus:get() method (lines 61-67) assembles the display array:
function RequestStatus:get()
local result = { self.status_line:to_string() } -- spinner + title
for _, line in ipairs(self.lines) do
table.insert(result, line) -- recent status lines
end
return result
end
The StatusLine:to_string() method (lines 24-28 of the same file) renders the current Braille character from the braille_chars table combined with the title text.
Pushing New Status Messages
To add messages to the rolling buffer, the RequestStatus:push() method (lines 70-75) maintains the size constraint:
function RequestStatus:push(line)
table.insert(self.lines, line)
if #self.lines > self.max_lines - 1 then -- keep only `max_lines-1` messages
table.remove(self.lines, 1) -- drop the oldest
end
end
This ensures the status display never exceeds the configured max_lines limit, preventing UI overflow during long-running operations.
Integration with Neovim's Virtual Text
The request status tracking system offers two output mechanisms: direct virtual text attachment via the Mark class, or custom handling through user callbacks.
Using Marks for Buffer Attachment
When initialized with a Mark object (from lua/99/ops/marks.lua), the RequestStatus instance updates virtual text directly on a buffer line. The Mark:set_virtual_text() method receives the string array from RequestStatus:get() and renders it as ephemeral text attached to the buffer coordinates defined during mark creation.
This approach is ideal for showing status inline with code, such as displaying "⠹ Running LSP request" on the line where the request originated.
Callback-Based Custom Rendering
Alternatively, passing a function as the fourth argument to RequestStatus.new() enables callback-driven updates:
local function ui_update(lines)
-- `lines` is a table like { "⠹ TITLE", "step 1", "step 2" }
vim.api.nvim_echo({ { table.concat(lines, "\n") } }, false, {})
end
local status = RequestStatus.new(200, 4, "Deploying", ui_update)
This decouples the status tracker from Neovim's virtual text system, allowing integration with external UI plugins, logging systems, or remote monitoring tools.
Practical Implementation Examples
Example 1: Show a Spinner in the Current Buffer
This example demonstrates attaching a status tracker to a specific buffer line using the Mark class:
local RequestStatus = require("99.ops.request_status")
local Mark = require("99.ops.marks")
local test_utils = require("99.test.test_utils") -- helper to obtain a buffer
local buf = test_utils.create_file({ "function foo() end" }, "lua", 1, 1)
local point = require("99.geo").Point:from_1_based(1, 1)
local mark = Mark.mark_point(buf, point)
-- Show a spinner that updates every 150 ms and keeps up to 5 lines
local status = RequestStatus.new(150, 5, "Running LSP request", mark)
status:start()
-- Append status messages as the request progresses
status:push("Sending query…")
status:push("Awaiting response…")
-- …
-- When done:
status:stop()
Example 2: Feed Status to a Custom UI via Callback
For scenarios requiring custom display logic, use the callback interface:
local RequestStatus = require("99.ops.request_status")
local function ui_update(lines)
-- `lines` is a table like { "⠹ TITLE", "step 1", "step 2" }
vim.api.nvim_echo({ { table.concat(lines, "\n") } }, false, {})
end
local status = RequestStatus.new(200, 4, "Deploying", ui_update)
status:start()
-- Simulate progress
status:push("Building")
vim.wait(500) -- wait a bit
status:push("Uploading")
vim.wait(500)
status:stop()
Summary
- The request status tracking system centers on the
RequestStatusclass inlua/99/ops/request_status.lua, which orchestrates visual feedback for asynchronous operations. - It combines a Braille-character spinner (
StatusLine), a rolling message buffer (lines), and either a virtual text mark or user callback for output. - The update mechanism relies on
vim.defer_fnto create a non-blocking loop that refreshes the display everyupdate_timemilliseconds untilstop()setsrunningto false. - Message history is automatically capped using
max_lines, ensuring the UI remains concise during long-running tasks.
Frequently Asked Questions
How does the RequestStatus class update the spinner animation without blocking Neovim?
The class uses vim.defer_fn to schedule asynchronous updates. When start() is called, it initiates a recursive function that updates the spinner index, renders the current state, and then reschedules itself after update_time milliseconds. This creates a cooperative multitasking loop that yields control back to Neovim between ticks, preventing UI freezing.
What is the difference between using a Mark and a callback in RequestStatus?
When initialized with a Mark object (from lua/99/ops/marks.lua), RequestStatus calls mark:set_virtual_text() on every tick to display status directly on a specific buffer line as ephemeral virtual text. When initialized with a callback function, the class passes the status line array to that function instead, allowing developers to route the data to custom UI components, log files, or external monitoring systems.
How does the status message buffer prevent memory leaks during long operations?
The RequestStatus:push() method enforces a hard limit on stored messages through the max_lines parameter set during construction. When new lines are added via table.insert(), the code checks if the buffer exceeds max_lines - 1 entries. If so, it immediately removes the oldest entry with table.remove(self.lines, 1), ensuring the memory footprint remains constant regardless of operation duration.
Where is the Braille spinner character sequence defined and how is it advanced?
The Braille spinner sequence is managed by the StatusLine class instantiated within RequestStatus. The StatusLine:update() method advances an internal index that cycles through a table of Braille characters (defined as braille_chars). On each tick of the main defer loop, self.status_line:update() is called to advance the spinner, and self.status_line:to_string() renders the current Braille character concatenated with the title line provided during construction.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →