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
signed, reported, fixes
- Author Szymon Wilczek author
- Merged-by Justin M. Keyes maintainer · merged it
diff
@@ -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). ============================================================================== @@ -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 @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 @@ -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 @@ -137,4 +137,5 @@ typedef enum { VV_VIRTNUM, VV_STARTTIME, VV_EXITREASON,+ VV_USERACTIVE, } VimVarIndex; @@ -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; @@ -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 = [=[ @@ -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
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
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 suggestionjustinmk 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-L277szymonwilczek author 28 Apr 2026
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
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
input_enqueue?
Edit: I saw zeertzjq edit, thanks, that clarifies it. I'll move it to input_enqueue()justinmk maintainer 28 Apr 2026
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
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
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 wayjustinmk maintainer 28 Apr 2026
os.time() - v:lastactivity still workstbung reviewer 28 Apr 2026
(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
os.time() - v:lastactivity still works
Makes sense, I’ll initialize to 0 then.szymonwilczek author 28 Apr 2026
(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
early_init, VAR_NUMBER defaults to 0, which signals no user activity since startup.justinmk maintainer 6 May 2026
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
os_realtime() and use now - last >= 1e9 for the throttle. Will update the PR in some time.justinmk maintainer 6 May 2026
v:useractivity expresses the semantics better?justinmk maintainer 6 May 2026
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
justinmk maintainer 7 May 2026
v:useractiveszymonwilczek author 7 May 2026
justinmk maintainer 7 May 2026
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
justinmk maintainer 7 May 2026
justinmk maintainer 7 May 2026
Timestamp indicating the most recent user activity.
Same precision as |v:starttime|.justinmk maintainer 7 May 2026
szymonwilczek author 7 May 2026
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.