neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

commit e0d179561dc813b813703cc1a092be23ff8866b5
parent 9d1333a385fc9e00716215c14c73a4254e048a39
Author: Gregory Anders <greg@gpanders.com>
Date:   Thu, 17 Jul 2025 18:47:33 -0500

Merge pull request #34860 from gpanders/push-lorwmnmtysnt

feat(tui): use DA1 response to determine OSC 52 support
Diffstat:
Mruntime/doc/api.txt | 6+++---
Mruntime/doc/autocmd.txt | 2+-
Mruntime/doc/news.txt | 2+-
Mruntime/doc/vim_diff.txt | 4++--
Mruntime/plugin/osc52.lua | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Msrc/nvim/api/ui.c | 2+-
Msrc/nvim/tui/input.c | 51+++++++++++++++++++++++++++++++++++++++++++--------
Msrc/nvim/vterm/state.c | 7++++++-
Mtest/functional/terminal/tui_spec.lua | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtest/unit/vterm_spec.lua | 2+-
10 files changed, 177 insertions(+), 40 deletions(-)

diff --git a/runtime/doc/api.txt b/runtime/doc/api.txt @@ -4130,9 +4130,9 @@ nvim_ui_term_event({event}, {value}) *nvim_ui_term_event()* Tells Nvim when a terminal event has occurred The following terminal events are supported: - • "termresponse": The terminal sent an OSC, DCS, or APC response sequence - to Nvim. The payload is the received response. Sets |v:termresponse| and - fires |TermResponse|. + • "termresponse": The terminal sent a DA1, OSC, DCS, or APC response + sequence to Nvim. The payload is the received response. Sets + |v:termresponse| and fires |TermResponse|. Attributes: ~ |RPC| only diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt @@ -1043,7 +1043,7 @@ TermRequest When a |:terminal| child process emits an OSC, autocommand defined without |autocmd-nested|. *TermResponse* -TermResponse When Nvim receives an OSC, DCS, or APC response from +TermResponse When Nvim receives a DA1, OSC, DCS, or APC response from the host terminal. Sets |v:termresponse|. The |event-data| is a table with the following fields: diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt @@ -270,7 +270,7 @@ TREESITTER TUI -• |TermResponse| now supports APC query responses. +• |TermResponse| now supports DA1 and APC query responses. UI diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt @@ -326,8 +326,8 @@ Events (autocommands): - |TabNewEntered| - |TermClose| - |TermOpen| -- |TermResponse| is fired for any OSC sequence received from the terminal, - instead of the Primary Device Attributes response. |v:termresponse| +- |TermResponse| is fired for DCS, OSC, and APC sequences received from the terminal, + in addition to the Primary Device Attributes response. |v:termresponse| - |UIEnter| - |UILeave| diff --git a/runtime/plugin/osc52.lua b/runtime/plugin/osc52.lua @@ -19,33 +19,77 @@ vim.api.nvim_create_autocmd('UIEnter', { end end - -- Do not query when any of the following is true: - -- * No TUI is attached - -- * Using a badly behaved terminal - if not tty or vim.env.TERM_PROGRAM == 'Apple_Terminal' then + -- Do not query when no TUI is attached + if not tty then + return + end + + -- Clear existing OSC 52 value, since this is a new UI we might be attached to a different + -- terminal + do local termfeatures = vim.g.termfeatures or {} ---@type TermFeatures termfeatures.osc52 = nil vim.g.termfeatures = termfeatures - return end - require('vim.termcap').query('Ms', function(cap, found, seq) - if not found then - return - end + -- Check DA1 first + vim.api.nvim_create_autocmd('TermResponse', { + group = id, + nested = true, + callback = function(args) + local resp = args.data.sequence ---@type string + local params = resp:match('^\027%[%?([%d;]+)c$') + if params then + -- Check termfeatures again, it may have changed between the query and response. + if vim.g.termfeatures ~= nil and vim.g.termfeatures.osc52 ~= nil then + return true + end - assert(cap == 'Ms') + for param in string.gmatch(params, '%d+') do + if param == '52' then + local termfeatures = vim.g.termfeatures or {} ---@type TermFeatures + termfeatures.osc52 = true + vim.g.termfeatures = termfeatures + return true + end + end - -- If the terminal reports a sequence other than OSC 52 for the Ms capability - -- then ignore it. We only support OSC 52 (for now) - if not seq or not seq:match('^\027%]52') then - return - end + -- Do not use XTGETTCAP on terminals that echo unknown sequences + if vim.env.TERM_PROGRAM == 'Apple_Terminal' then + return true + end - local termfeatures = vim.g.termfeatures or {} ---@type TermFeatures - termfeatures.osc52 = true - vim.g.termfeatures = termfeatures - end) + -- Fallback to XTGETTCAP + require('vim.termcap').query('Ms', function(cap, found, seq) + if not found then + return + end + + -- Check termfeatures again, it may have changed between the query and response. + if vim.g.termfeatures ~= nil and vim.g.termfeatures.osc52 ~= nil then + return + end + + assert(cap == 'Ms') + + -- If the terminal reports a sequence other than OSC 52 for the Ms capability + -- then ignore it. We only support OSC 52 (for now) + if not seq or not seq:match('^\027%]52') then + return + end + + local termfeatures = vim.g.termfeatures or {} ---@type TermFeatures + termfeatures.osc52 = true + vim.g.termfeatures = termfeatures + end) + + return true + end + end, + }) + + -- Write DA1 request + io.stdout:write('\027[c') end, }) diff --git a/src/nvim/api/ui.c b/src/nvim/api/ui.c @@ -543,7 +543,7 @@ void nvim_ui_pum_set_bounds(uint64_t channel_id, Float width, Float height, Floa /// /// The following terminal events are supported: /// -/// - "termresponse": The terminal sent an OSC, DCS, or APC response sequence to +/// - "termresponse": The terminal sent a DA1, OSC, DCS, or APC response sequence to /// Nvim. The payload is the received response. Sets /// |v:termresponse| and fires |TermResponse|. /// diff --git a/src/nvim/tui/input.c b/src/nvim/tui/input.c @@ -577,7 +577,7 @@ static size_t handle_bracketed_paste(TermInput *input, const char *ptr, size_t s return 0; } -/// Handle an OSC or DCS response sequence from the terminal. +/// Handle an OSC, DCS, or APC response sequence from the terminal. static void handle_term_response(TermInput *input, const TermKeyKey *key) FUNC_ATTR_NONNULL_ALL { @@ -622,6 +622,47 @@ static void handle_term_response(TermInput *input, const TermKeyKey *key) } } +/// Handle a Primary Device Attributes (DA1) response from the terminal. +static void handle_primary_device_attr(TermInput *input, TermKeyCsiParam *params, size_t nparams) + FUNC_ATTR_NONNULL_ALL +{ + if (input->callbacks.primary_device_attr) { + void (*cb_save)(TUIData *) = input->callbacks.primary_device_attr; + // Clear the callback before invoking it, as it may set a new callback. #34031 + input->callbacks.primary_device_attr = NULL; + cb_save(input->tui_data); + } + + if (nparams == 0) { + return; + } + + MAXSIZE_TEMP_ARRAY(args, 2); + ADD_C(args, STATIC_CSTR_AS_OBJ("termresponse")); + + StringBuilder response = KV_INITIAL_VALUE; + kv_concat(response, "\x1b[?"); + + for (size_t i = 0; i < nparams; i++) { + int arg; + if (termkey_interpret_csi_param(params[i], &arg, NULL, NULL) != TERMKEY_RES_KEY) { + goto out; + } + + kv_printf(response, "%d", arg); + if (i < nparams - 1) { + kv_push(response, ';'); + } + } + + kv_push(response, 'c'); + + ADD_C(args, STRING_OBJ(cbuf_as_string(response.items, response.size))); + rpc_send_event(ui_client_channel_id, "nvim_ui_term_event", args); +out: + kv_destroy(response); +} + /// Handle a mode report (DECRPM) sequence from the terminal. static void handle_modereport(TermInput *input, const TermKeyKey *key) FUNC_ATTR_NONNULL_ALL @@ -668,13 +709,7 @@ static void handle_unknown_csi(TermInput *input, const TermKeyKey *key) switch (initial) { case '?': // Primary Device Attributes (DA1) response - if (input->callbacks.primary_device_attr) { - void (*cb_save)(TUIData *) = input->callbacks.primary_device_attr; - // Clear the callback before invoking it, as it may set a new callback. #34031 - input->callbacks.primary_device_attr = NULL; - cb_save(input->tui_data); - } - + handle_primary_device_attr(input, params, nparams); break; } break; diff --git a/src/nvim/vterm/state.c b/src/nvim/vterm/state.c @@ -17,6 +17,11 @@ #define strneq(a, b, n) (strncmp(a, b, n) == 0) +// Primary Device Attributes (DA1) response. +// We make this a global (extern) variable so that we can override it with FFI +// in tests. +char vterm_primary_device_attr[] = "61;22;52"; + // Some convenient wrappers to make callback functions easier static void putglyph(VTermState *state, const schar_T schar, int width, VTermPos pos) @@ -1385,7 +1390,7 @@ static int on_csi(const char *leader, const long args[], int argcount, const cha val = CSI_ARG_OR(args[0], 0); if (val == 0) { // DEC VT100 response - vterm_push_output_sprintf_ctrl(state->vt, C1_CSI, "?1;2c"); + vterm_push_output_sprintf_ctrl(state->vt, C1_CSI, "?%sc", vterm_primary_device_attr); } break; diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua @@ -3410,9 +3410,24 @@ describe('TUI', function() end) end) - it('queries the terminal for OSC 52 support', function() + it('queries the terminal for OSC 52 support with XTGETTCAP', function() clear() + if not exec_lua('return pcall(require, "ffi")') then + pending('missing LuaJIT FFI') + end + + -- Change vterm's DA1 response so that it doesn't include 52 + exec_lua(function() + local ffi = require('ffi') + ffi.cdef [[ + extern char vterm_primary_device_attr[] + ]] + + ffi.copy(ffi.C.vterm_primary_device_attr, '61;22') + end) + exec_lua([[ + _G.query = false vim.api.nvim_create_autocmd('TermRequest', { callback = function(args) local req = args.data.sequence @@ -3420,6 +3435,7 @@ describe('TUI', function() if sequence and vim.text.hexdecode(sequence) == 'Ms' then local resp = string.format('\027P1+r%s=%s\027\\', sequence, vim.text.hexencode('\027]52;;\027\\')) vim.api.nvim_chan_send(vim.bo[args.buf].channel, resp) + _G.query = true return true end end, @@ -3430,7 +3446,6 @@ describe('TUI', function() screen = tt.setup_child_nvim({ '--listen', child_server, - -- Use --clean instead of -u NONE to load the osc52 plugin '--clean', }, { env = { @@ -3444,6 +3459,7 @@ describe('TUI', function() retry(nil, 1000, function() eq({ true, { osc52 = true } }, { child_session:request('nvim_eval', 'g:termfeatures') }) end) + eq(true, exec_lua([[return _G.query]])) -- Attach another (non-TUI) UI to the child instance local alt = Screen.new(nil, nil, nil, child_session) @@ -3462,6 +3478,43 @@ describe('TUI', function() eq({ true, {} }, { child_session:request('nvim_eval', 'g:termfeatures') }) end) + it('determines OSC 52 support from DA1 response', function() + clear() + exec_lua([[ + -- Check that we do not emit an XTGETTCAP request when DA1 indicates support + _G.query = false + vim.api.nvim_create_autocmd('TermRequest', { + callback = function(args) + local req = args.data.sequence + local sequence = req:match('^\027P%+q([%x;]+)$') + if sequence and vim.text.hexdecode(sequence) == 'Ms' then + _G.query = true + return true + end + end, + }) + ]]) + + local child_server = new_pipename() + screen = tt.setup_child_nvim({ + '--listen', + child_server, + '--clean', + }, { + env = { + VIMRUNTIME = os.getenv('VIMRUNTIME'), + }, + }) + + screen:expect({ any = '%[No Name%]' }) + + local child_session = n.connect(child_server) + retry(nil, 1000, function() + eq({ true, { osc52 = true } }, { child_session:request('nvim_eval', 'g:termfeatures') }) + end) + eq(false, exec_lua([[return _G.query]])) + end) + it('does not query the terminal for OSC 52 support when disabled', function() clear() exec_lua([[ @@ -3472,6 +3525,7 @@ describe('TUI', function() local sequence = req:match('^\027P%+q([%x;]+)$') if sequence and vim.text.hexdecode(sequence) == 'Ms' then _G.query = true + return true end end, }) @@ -3481,7 +3535,6 @@ describe('TUI', function() screen = tt.setup_child_nvim({ '--listen', child_server, - -- Use --clean instead of -u NONE to load the osc52 plugin '--clean', '--cmd', 'let g:termfeatures = #{osc52: v:false}', diff --git a/test/unit/vterm_spec.lua b/test/unit/vterm_spec.lua @@ -2659,7 +2659,7 @@ putglyph 1f3f4,200d,2620,fe0f 2 0,4]]) -- DA reset(state, nil) push('\x1b[c', vt) - expect_output('\x1b[?1;2c') + expect_output('\x1b[?61;22;52c') -- XTVERSION reset(state, nil)