Szymon Wilczek

research / neovim/neovim / feat(server) / 3639f7a8672b

add v:useractive, use it in serverlist(info=true)

neovim/neovim

feat(server) +82 −21

From: Szymon Wilczek <[email protected]>
Date: 26 Apr 2026
Subject: feat(server): add v:useractive, use it in serverlist(info=true)
Pull request: #39423
Merged: 7 May 2026, by Justin M. Keyes

Problem

UI pickers for remote servers (like :connect) need to know which servers are actively used or stale. Currently, Nvim does not expose a mechanism to track user activity timestamps.

Solution

  • Introduce v:useractive as read-only Unix timestamp updated by vgetc() upon user keystrokes.
  • Throttle the update to 1-sec granularity to avoid overhead.
  • Expose this timestamp as last_activity in the dicts returned by serverlist({info=true}) with internal RPC call.

Addresses the [last-activity] requirement from #39420.

Note: Stacked on top of #39392. Diff currently includes commits from the prerequisite PR, but will automatically shrink once #39392 merges into master.

signed, reported, fixes

  • Author Szymon Wilczek author
  • Merged-by Justin M. Keyes maintainer · merged it

diff

runtime/doc/news.txt +1 −0
@@ -241,6 +241,7 @@ VIMSCRIPT  • |v:exitreason| is set before |QuitPre|. • |v:starttime| is the process start time (nanoseconds since UNIX epoch).+• |v:useractive| timestamp of the most recent user activity. • |serverlist()| with `info=true` returns details for each server (own + peers).  ==============================================================================
runtime/doc/vimfn.txt +5 −6
@@ -9142,12 +9142,11 @@ serverlist([{opts}])                                              *serverlist()* 		  info  : If |TRUE|, return a list of Dicts with detailed info 		          instead of addresses. Implies `peer=true`. Each Dict 		          has the following items:-		            addr   (string)  Server address.-		            pid    (number?) PID of the Nvim process owning the-		                             server, or |v:null| if the peer is-		                             unreachable.-		            own    (bool)    Whether this server belongs to the-		                             current Nvim instance.+		            addr          (string)  Server address.+		            pid           (number)  PID of the Nvim process.+		            own           (bool)    Whether this server belongs to+		                                    the current Nvim instance.+		            active        (number)  |v:useractive| of the server. 		          (default: |FALSE|)  		Example: >vim
runtime/doc/vvars.txt +8 −0
@@ -754,6 +754,14 @@ v:true 		operator) and to one when used as a Number (e.g. in |expr5| or 		|expr7| when used with numeric operators).  Read-only. +				*v:useractive* *useractive-variable*+v:useractive+		Timestamp indicating the most recent user activity.+		Updated every time a key is received from a UI+		(TUI keystrokes or RPC |nvim_input()|).+		Initialized to 0 (no user activity since startup).+		Read-only.+ 						*v:val* *val-variable* v:val 		Value of the current item of a |List| or |Dictionary|.  Only
runtime/lua/vim/_core/server.lua +9 −1
@@ -6,8 +6,9 @@ local M = {} --- ---@class vim.ServerInfo ---@field addr string Server address (socket path, named pipe, or TCP host:port).----@field pid integer? PID of the Nvim process owning this server (nil if unreachable).+---@field pid integer PID of the Nvim process. ---@field own boolean True if this server belongs to the current Nvim instance.+---@field active integer Timestamp of last user activity.  --- @param opts? table Options: ---              - opts.peer (boolean): If true, also discover peer servers.@@ -42,6 +43,7 @@ function M.serverlist(opts, addrs)   end, { path = root, type = 'socket', limit = math.huge })    local peer_pids = {} ---@type table<string, integer>+  local peer_active = {} ---@type table<string, integer>    for _, socket in ipairs(socket_paths) do     if not own_set[socket] and not vim.list_contains(addrs, socket) then@@ -57,6 +59,10 @@ function M.serverlist(opts, addrs)             if ok_pid and type(pid) == 'number' then               peer_pids[socket] = pid             end+            local ok_la, la = pcall(vim.fn.rpcrequest, chan, 'nvim_get_vvar', 'useractive')+            if ok_la and type(la) == 'number' then+              peer_active[socket] = la+            end           end         end         pcall(vim.fn.chanclose, chan)@@ -69,13 +75,15 @@ function M.serverlist(opts, addrs)   end    local self_pid = vim.fn.getpid()+  local self_active = vim.v.useractive   local result = {} ---@type vim.ServerInfo[]   for _, addr in ipairs(addrs) do     local own = own_set[addr] == true     table.insert(result, {       addr = addr,       pid = own and self_pid or peer_pids[addr],       own = own,+      active = own and self_active or peer_active[addr],     })   end   return result
runtime/lua/vim/_meta/vimfn.gen.lua +5 −6
@@ -8292,12 +8292,11 @@ function vim.fn.searchpos(pattern, flags, stopline, timeout, skip) end ---   info  : If |TRUE|, return a list of Dicts with detailed info ---           instead of addresses. Implies `peer=true`. Each Dict ---           has the following items:----             addr   (string)  Server address.----             pid    (number?) PID of the Nvim process owning the----                              server, or |v:null| if the peer is----                              unreachable.----             own    (bool)    Whether this server belongs to the----                              current Nvim instance.+---             addr          (string)  Server address.+---             pid           (number)  PID of the Nvim process.+---             own           (bool)    Whether this server belongs to+---                                     the current Nvim instance.+---             active        (number)  |v:useractive| of the server. ---           (default: |FALSE|) --- --- Example: >vim
runtime/lua/vim/_meta/vvars.gen.lua +8 −0
@@ -805,6 +805,14 @@ vim.v.throwpoint = ... --- @type boolean vim.v['true'] = ... +--- Timestamp indicating the most recent user activity.+--- Updated every time a key is received from a UI+--- (TUI keystrokes or RPC `nvim_input()`).+--- Initialized to 0 (no user activity since startup).+--- Read-only.+--- @type integer+vim.v.useractive = ...+ --- Value of the current item of a `List` or `Dictionary`.  Only --- valid while evaluating the expression used with `map()` and --- `filter()`.  Read-only.
src/nvim/eval.lua +5 −6
@@ -9974,12 +9974,11 @@ M.funcs = {         info  : If |TRUE|, return a list of Dicts with detailed info                 instead of addresses. Implies `peer=true`. Each Dict                 has the following items:-                  addr   (string)  Server address.-                  pid    (number?) PID of the Nvim process owning the-                                   server, or |v:null| if the peer is-                                   unreachable.-                  own    (bool)    Whether this server belongs to the-                                   current Nvim instance.+                  addr          (string)  Server address.+                  pid           (number)  PID of the Nvim process.+                  own           (bool)    Whether this server belongs to+                                          the current Nvim instance.+                  active        (number)  |v:useractive| of the server.                 (default: |FALSE|)        Example: >vim
src/nvim/eval/vars.c +1 −0
@@ -217,6 +217,7 @@ static struct vimvar {   VV(VV_VIRTNUM,          "virtnum",          VAR_NUMBER, VV_RO),   VV(VV_STARTTIME,        "starttime",        VAR_NUMBER, VV_RO),   VV(VV_EXITREASON,       "exitreason",       VAR_STRING, VV_RO),+  VV(VV_USERACTIVE,       "useractive",       VAR_NUMBER, VV_RO), }; #undef VV 
src/nvim/eval_defs.h +1 −0
@@ -137,4 +137,5 @@ typedef enum {   VV_VIRTNUM,   VV_STARTTIME,   VV_EXITREASON,+  VV_USERACTIVE, } VimVarIndex;
src/nvim/os/input.c +8 −0
@@ -11,6 +11,10 @@ #include "nvim/autocmd.h" #include "nvim/autocmd_defs.h" #include "nvim/buffer_defs.h"+#include "nvim/eval.h"+#include "nvim/eval/typval_defs.h"+#include "nvim/eval/vars.h"+#include "nvim/eval_defs.h" #include "nvim/event/loop.h" #include "nvim/event/multiqueue.h" #include "nvim/event/rstream.h"@@ -276,6 +280,10 @@ size_t input_enqueue(uint64_t chan_id, String keys) {   current_ui = chan_id; +  if (keys.size > 0) {+    set_vim_var_nr(VV_USERACTIVE, (varnumber_T)os_realtime());+  }+   const char *ptr = keys.data;   const char *end = ptr + keys.size; 
src/nvim/vvars.lua +10 −0
@@ -447,6 +447,16 @@ M.vars = {       encoding.  See |multi-lang|.     ]=],   },+  useractive = {+    type = 'integer',+    desc = [=[+      Timestamp indicating the most recent user activity.+      Updated every time a key is received from a UI+      (TUI keystrokes or RPC |nvim_input()|).+      Initialized to 0 (no user activity since startup).+      Read-only.+    ]=],+  },   lc_time = {     type = 'string',     desc = [=[
test/functional/core/server_spec.lua +21 −2
@@ -231,25 +231,44 @@ describe('server', function()       by_addr[entry.addr] = entry     end -    -- own server: own=true, pid matches+    -- own server: own=true, pid matches, active present     local own_entry = by_addr[own_addr]     eq(true, own_entry ~= nil)     eq(true, own_entry.own)     eq(self_pid, own_entry.pid)+    eq('number', type(own_entry.active))+    eq(eval('v:useractive'), own_entry.active) -    -- peer server: own=false, pid is from peer process+    -- peer server: own=false, pid + active from peer process     local peer_entry = by_addr[peer_addr]     eq(true, peer_entry ~= nil)     eq(false, peer_entry.own)     eq('number', type(peer_entry.pid))+    eq('number', type(peer_entry.active))     n.set_session(client)     local peer_pid = fn.getpid()+    local peer_active = eval('v:useractive')     n.set_session(current_server)     eq(peer_pid, peer_entry.pid)+    eq(peer_active, peer_entry.active)      client:close()   end) +  it('v:useractive advances on UI input', function()+    clear()+    local before = eval('v:useractive')+    eq('number', type(before))+    n.feed('ix<Esc>')+    local after = eval('v:useractive')+    eq(true, after > before)+  end)++  it('v:useractive is read-only', function()+    clear()+    matches('E46:', pcall_err(n.command, 'let v:useractive = 0'))+  end)+   it('removes stale socket files automatically #36581', function()     -- Windows named pipes are ephemeral kernel objects that are automatically     -- cleaned up when the process terminates. Unix domain sockets persist as

discussion · on the pull request

v1 · 28 messages

szymonwilczek author 26 Apr 2026

feat(server): add v:useractive, use it in serverlist(info=true)

tbung reviewer 28 Apr 2026

Could use os_realtime introduced in #39425. Pro argument is that v:starttime and v:lastactivity then have the same precision, con argument is that it complicates the 1sec debounce for precision that probably isn't needed.

szymonwilczek author 28 Apr 2026

Thanks for the suggestion, I've looked at #39425, but using os_realtime() here would actually break the throttling mechanism. It returns nanoseconds, meaning now != last_activity would evaluate to true on almost every single keystroke. To maintain 1-second throttle we'd have to introduce division by 1e9 in vgetc(), which is i think hot path. Since the goal is just to feed a UI picker with relative time (3 minutes ago etc), os_time gives us free & dirt-cheap 1 sec throttle OOTB. I think we should stick to the cheaper integer comparision here, but that just my suggestion

justinmk maintainer 28 Apr 2026

current_ui is a similar concept, set in input_enqueue , any reason not to do this there also? https://github.com/neovim/neovim/blob/72bc6c5801867e8f99a87737a64c1713ceac1e01/src/nvim/os/input.c#L275-L277

szymonwilczek author 28 Apr 2026

Hmm, good point. input_enqueue is external I/O boundary, called directly from nvim_input (so TUI keystrokes and RPC-driven input both flow through it). KeyTyped in vgetc(), on the other hand, is only set when the key came from a real user interaction, not from mappings or macros. If we move this to input_enqueue, a headless rpc plugin calling nvim_input would continuously update v:lastactivity, which might make it unreliable as a is the human actually at the keyboard signal. But maybe any external UI activity is exactly what we want to count here? I can move the logic to input_enqueue, or just add a comment to vgetc() clarifying my intent. Let me know what would you prefer for this.

zeertzjq reviewer 28 Apr 2026

All keys sent by input_enqueue() have KeyTyped, so there is no change in intent. Actually checking at input_enqueue() is better here. If a key typed by the user triggers a mapping, KeyTyped isn't set, but the user indeed has typed a key.

szymonwilczek author 28 Apr 2026

Thanks, then my reasoning about filtering out rpc input is no accurate. In that case, is there a meaningful difference between the two locations from your perspective, or would you prefer that I move this to input_enqueue? Edit: I saw zeertzjq edit, thanks, that clarifies it. I'll move it to input_enqueue()

justinmk maintainer 28 Apr 2026

> If we move this to input_enqueue, a headless rpc plugin calling nvim_input would continuously update v:lastactivity, which might make it unreliable as a is the human actually at the keyboard signal. nvim_input is "human" input, by definition. That is what all UIs use.

justinmk maintainer 28 Apr 2026

hmm, maybe VV_LASTACTIVITY should not be set here, i.e. it should be v:null or 0. Only actual user input should set it? idk if it matters, but it's kind of weird that with the current impl, it's not easy to compare v:starttime to v:lastactivity. however, based on https://github.com/neovim/neovim/pull/39423#discussion_r3153636004 it sounds like we may have to live with that.

szymonwilczek author 28 Apr 2026

My motivation was to avoid nil/null becks on the user side - pickers can always do os.time() - v:lastactivity without guarding. But if the intent is last user input, then 0 or v:null is more honest. Happy to change it either way

justinmk maintainer 28 Apr 2026

that's a good thought, but it is probably useful to be able to check that "there was no user activity since startup", right? so need some way to do that. if set to "epoch time", or zero, then os.time() - v:lastactivity still works

tbung reviewer 28 Apr 2026

We could do (now >> 30) != (last_activity >> 30) which should still be fast and gives us a ~1.07s throttle if we do want v:starttime and v:lastactivity to be comparable. v:lastactivity should then probably be initialized to v:starttime instead of a separate call though.

szymonwilczek author 28 Apr 2026

> that's a good thought, but it is probably useful to be able to check that "there was no user activity since startup", right? so need some way to do that. > > > > if set to "epoch time", or zero, then os.time() - v:lastactivity still works Makes sense, I’ll initialize to 0 then.

szymonwilczek author 28 Apr 2026

> We could do (now >> 30) != (last_activity >> 30) which should still be fast and gives us a ~1.07s throttle if we do want v:starttime and v:lastactivity to be comparable. > > > > v:lastactivity should then probably be initialized to v:starttime instead of a separate call though. Bit-shift trick is clever, but I’d rather keep input_enqueue readable. The 1-second throttle via os_time() is intentional and sufficient for a UI picker. As for initializing to v:starttime - they intentionally use different clocks (os_realtime nanoseconds vs os_time seconds), so mixing them would require division anyway, which kind of defeats the simplicity argument.

szymonwilczek author 28 Apr 2026

Done, I removed the initialization from early_init, VAR_NUMBER defaults to 0, which signals no user activity since startup.

justinmk maintainer 6 May 2026

> nanoseconds, meaning now != last_activity would evaluate to true on almost every single keystroke. I'm confused. Even if the granularity is "seconds", now == last is not a good way to throttle, you still should compare now - last >= 1 second. > To maintain 1-second throttle we'd have to introduce division by 1e9 in vgetc(), which is i think hot path. integer division is nothing compared to the rest of vgetc. no performance impact. So I don't think there is any reason not to use os_realtime. Having different granularities for v:lastactivity and v:starttime is a UX cost with negligible benefit.

szymonwilczek author 6 May 2026

Hmm, looking at it after some time: You're right on both points. I'll switch to os_realtime() and use now - last >= 1e9 for the throttle. Will update the PR in some time.

justinmk maintainer 6 May 2026

maybe v:useractivity expresses the semantics better?

justinmk maintainer 6 May 2026

why does this care about now != last ? it should just always update. set_vim_var_nr(VV_LASTACTIVITY, (varnumber_T)os_realtime); if we really want to avoid unnecessary operations, we could set a global, last_activity = os_realtime(..) and only update VV_LASTACTIVITY when v:lastactivity is actually requested from user code. I'm not sure if there's precedent for that, but AI could probably sketch something. It may require adding a special-case to get_vim_var_nr, or perhaps introducing a special function analogous to set_vcount (except for the "get" case: get_vlastactivity)

justinmk maintainer 7 May 2026

news doesn't need to document every little detail. it's intended to surface interesting changes. the full docs are linked. thus, the abbreviated version this is overwriting is preferred.

justinmk maintainer 7 May 2026

sorry for the churn, can we name it v:useractive

szymonwilczek author 7 May 2026

Sure thing, no problem

justinmk maintainer 7 May 2026

in this context we do not need to repeat the exact same name. here, just active is meaningful. also, the doc is over-explaining. since we link to |v:useractive| there is no need to repeat details already mentioned there.
		            active  (number?) |v:useractive|, or |v:null| if peer is unreachable.

justinmk maintainer 7 May 2026

i'm kind of wondering why we would return anything if the peer is unreachable. why is it in the list at all?

justinmk maintainer 7 May 2026

this is irrelevant

justinmk maintainer 7 May 2026

this is a nonsense way to explain this.
		Timestamp indicating the most recent user activity.
		Same precision as |v:starttime|.

justinmk maintainer 7 May 2026

I think this is good enough after comments are addressed. Thanks!

szymonwilczek author 7 May 2026

unreachable peers are already filtered out, the code only adds a peer to the list if sockconnect + nvim_get_chan_info both succeed. so the v:null case was effectively dead documentation. i removed it, pid and active are now documented as always-present number fields.
the whole thread on github.com
12/12
salina
aurum
herbarium
mysterium
parchment
whispergrove
M-x 0/0

keybindings & navigation

M-x / Alt+x Open Emacs command runner
Shift + W Jump to writings & essays (/writings)
Shift + R Jump to research & upstream work (/research)
Shift + T Choose colour theme (au-themes-select)
j / k Scroll down / up
g g Scroll to the top of page
Shift + G Scroll to the bottom of page
? Toggle this keybindings help cheat sheet
Esc / C-g Dismiss modal or close active menu