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:
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)