From 977e91b424f4cf6b9eacfa21d491d312f585dd6f Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Wed, 9 Jul 2025 12:23:19 -0500 Subject: [PATCH 1/2] feat(tui): use DA1 response to determine OSC 52 support Many terminals now include support for OSC 52 in their Primary Device Attributes (DA1) response. This is preferable to using XTGETTCAP because DA1 is _much_ more broadly supported. --- runtime/doc/api.txt | 6 +- runtime/doc/autocmd.txt | 2 +- runtime/doc/news.txt | 2 +- runtime/doc/vim_diff.txt | 4 +- runtime/plugin/osc52.lua | 88 ++++++++++++++++++++------- src/nvim/api/ui.c | 2 +- src/nvim/tui/input.c | 51 +++++++++++++--- src/nvim/vterm/state.c | 7 ++- test/functional/terminal/tui_spec.lua | 59 +++++++++++++++++- test/unit/vterm_spec.lua | 2 +- 10 files changed, 180 insertions(+), 43 deletions(-) diff --git a/runtime/doc/api.txt b/runtime/doc/api.txt index 6e734c1645..94a527f78f 100644 --- 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 index 9091199daf..6524e787a4 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -1049,7 +1049,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 index 1fe6594958..9a90d9b14c 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -261,7 +261,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 index 74e9a597c2..c52029c453 100644 --- a/runtime/doc/vim_diff.txt +++ b/runtime/doc/vim_diff.txt @@ -325,8 +325,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 index e9173f6a1b..3ca7c5e3c0 100644 --- 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 - local termfeatures = vim.g.termfeatures or {} ---@type TermFeatures - termfeatures.osc52 = nil - vim.g.termfeatures = termfeatures + -- Do not query when no TUI is attached + if not tty then return end - require('vim.termcap').query('Ms', function(cap, found, seq) - if not found 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 - + -- 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 = true + termfeatures.osc52 = nil vim.g.termfeatures = termfeatures - end) + 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 + + 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 + + -- Do not use XTGETTCAP on terminals that echo unknown sequences + if vim.env.TERM_PROGRAM == 'Apple_Terminal' then + return true + 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 index ba55e235a1..68c973791b 100644 --- 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 index 822fdd9202..f3dd793115 100644 --- 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 index 3b7b5e2997..56b6639bfc 100644 --- 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[] = "1;2;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 index 2cab1e8006..7b86d5ff69 100644 --- a/test/functional/terminal/tui_spec.lua +++ b/test/functional/terminal/tui_spec.lua @@ -3526,9 +3526,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, '1;2') + end) + exec_lua([[ + _G.query = false vim.api.nvim_create_autocmd('TermRequest', { callback = function(args) local req = args.data.sequence @@ -3536,6 +3551,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, @@ -3546,7 +3562,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 = { @@ -3560,6 +3575,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) @@ -3578,6 +3594,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([[ @@ -3588,6 +3641,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, }) @@ -3597,7 +3651,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 index aab895b7dc..3c721523b0 100644 --- 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[?1;2;52c') -- XTVERSION reset(state, nil) From 112092271be5993e8050665b066a0e924463b2a7 Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Wed, 9 Jul 2025 17:30:34 -0500 Subject: [PATCH 2/2] refactor(vterm): update vterm DA1 response Update vterm's DA1 response to the more modern version that indicates level 1 support for VT100 emulation (61) as well as ANSI color support (22). --- src/nvim/vterm/state.c | 2 +- test/functional/terminal/tui_spec.lua | 2 +- test/unit/vterm_spec.lua | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nvim/vterm/state.c b/src/nvim/vterm/state.c index 56b6639bfc..05713ea3ff 100644 --- a/src/nvim/vterm/state.c +++ b/src/nvim/vterm/state.c @@ -20,7 +20,7 @@ // 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[] = "1;2;52"; +char vterm_primary_device_attr[] = "61;22;52"; // Some convenient wrappers to make callback functions easier diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua index 7b86d5ff69..bad3ecde13 100644 --- a/test/functional/terminal/tui_spec.lua +++ b/test/functional/terminal/tui_spec.lua @@ -3539,7 +3539,7 @@ describe('TUI', function() extern char vterm_primary_device_attr[] ]] - ffi.copy(ffi.C.vterm_primary_device_attr, '1;2') + ffi.copy(ffi.C.vterm_primary_device_attr, '61;22') end) exec_lua([[ diff --git a/test/unit/vterm_spec.lua b/test/unit/vterm_spec.lua index 3c721523b0..fe4bc93722 100644 --- 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;2;52c') + expect_output('\x1b[?61;22;52c') -- XTVERSION reset(state, nil)