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.
This commit is contained in:
Gregory Anders
2025-07-09 12:23:19 -05:00
parent 76f6868e0a
commit 977e91b424
10 changed files with 180 additions and 43 deletions

View File

@@ -4130,9 +4130,9 @@ nvim_ui_term_event({event}, {value}) *nvim_ui_term_event()*
Tells Nvim when a terminal event has occurred Tells Nvim when a terminal event has occurred
The following terminal events are supported: The following terminal events are supported:
• "termresponse": The terminal sent an OSC, DCS, or APC response sequence • "termresponse": The terminal sent a DA1, OSC, DCS, or APC response
to Nvim. The payload is the received response. Sets |v:termresponse| and sequence to Nvim. The payload is the received response. Sets
fires |TermResponse|. |v:termresponse| and fires |TermResponse|.
Attributes: ~ Attributes: ~
|RPC| only |RPC| only

View File

@@ -1049,7 +1049,7 @@ TermRequest When a |:terminal| child process emits an OSC,
autocommand defined without |autocmd-nested|. autocommand defined without |autocmd-nested|.
*TermResponse* *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 the host terminal. Sets |v:termresponse|. The
|event-data| is a table with the following fields: |event-data| is a table with the following fields:

View File

@@ -261,7 +261,7 @@ TREESITTER
TUI TUI
• |TermResponse| now supports APC query responses. • |TermResponse| now supports DA1 and APC query responses.
UI UI

View File

@@ -325,8 +325,8 @@ Events (autocommands):
- |TabNewEntered| - |TabNewEntered|
- |TermClose| - |TermClose|
- |TermOpen| - |TermOpen|
- |TermResponse| is fired for any OSC sequence received from the terminal, - |TermResponse| is fired for DCS, OSC, and APC sequences received from the terminal,
instead of the Primary Device Attributes response. |v:termresponse| in addition to the Primary Device Attributes response. |v:termresponse|
- |UIEnter| - |UIEnter|
- |UILeave| - |UILeave|

View File

@@ -19,33 +19,77 @@ vim.api.nvim_create_autocmd('UIEnter', {
end end
end end
-- Do not query when any of the following is true: -- Do not query when no TUI is attached
-- * No TUI is attached if not tty then
-- * 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
return return
end end
require('vim.termcap').query('Ms', function(cap, found, seq) -- Clear existing OSC 52 value, since this is a new UI we might be attached to a different
if not found then -- terminal
return do
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 local termfeatures = vim.g.termfeatures or {} ---@type TermFeatures
termfeatures.osc52 = true termfeatures.osc52 = nil
vim.g.termfeatures = termfeatures 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, end,
}) })

View File

@@ -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: /// 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 /// Nvim. The payload is the received response. Sets
/// |v:termresponse| and fires |TermResponse|. /// |v:termresponse| and fires |TermResponse|.
/// ///

View File

@@ -577,7 +577,7 @@ static size_t handle_bracketed_paste(TermInput *input, const char *ptr, size_t s
return 0; 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) static void handle_term_response(TermInput *input, const TermKeyKey *key)
FUNC_ATTR_NONNULL_ALL 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. /// Handle a mode report (DECRPM) sequence from the terminal.
static void handle_modereport(TermInput *input, const TermKeyKey *key) static void handle_modereport(TermInput *input, const TermKeyKey *key)
FUNC_ATTR_NONNULL_ALL FUNC_ATTR_NONNULL_ALL
@@ -668,13 +709,7 @@ static void handle_unknown_csi(TermInput *input, const TermKeyKey *key)
switch (initial) { switch (initial) {
case '?': case '?':
// Primary Device Attributes (DA1) response // Primary Device Attributes (DA1) response
if (input->callbacks.primary_device_attr) { handle_primary_device_attr(input, params, nparams);
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);
}
break; break;
} }
break; break;

View File

@@ -17,6 +17,11 @@
#define strneq(a, b, n) (strncmp(a, b, n) == 0) #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 // Some convenient wrappers to make callback functions easier
static void putglyph(VTermState *state, const schar_T schar, int width, VTermPos pos) 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); val = CSI_ARG_OR(args[0], 0);
if (val == 0) { if (val == 0) {
// DEC VT100 response // 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; break;

View File

@@ -3526,9 +3526,24 @@ describe('TUI', function()
end) end)
end) end)
it('queries the terminal for OSC 52 support', function() it('queries the terminal for OSC 52 support with XTGETTCAP', function()
clear() 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([[ exec_lua([[
_G.query = false
vim.api.nvim_create_autocmd('TermRequest', { vim.api.nvim_create_autocmd('TermRequest', {
callback = function(args) callback = function(args)
local req = args.data.sequence local req = args.data.sequence
@@ -3536,6 +3551,7 @@ describe('TUI', function()
if sequence and vim.text.hexdecode(sequence) == 'Ms' then 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\\')) 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) vim.api.nvim_chan_send(vim.bo[args.buf].channel, resp)
_G.query = true
return true return true
end end
end, end,
@@ -3546,7 +3562,6 @@ describe('TUI', function()
screen = tt.setup_child_nvim({ screen = tt.setup_child_nvim({
'--listen', '--listen',
child_server, child_server,
-- Use --clean instead of -u NONE to load the osc52 plugin
'--clean', '--clean',
}, { }, {
env = { env = {
@@ -3560,6 +3575,7 @@ describe('TUI', function()
retry(nil, 1000, function() retry(nil, 1000, function()
eq({ true, { osc52 = true } }, { child_session:request('nvim_eval', 'g:termfeatures') }) eq({ true, { osc52 = true } }, { child_session:request('nvim_eval', 'g:termfeatures') })
end) end)
eq(true, exec_lua([[return _G.query]]))
-- Attach another (non-TUI) UI to the child instance -- Attach another (non-TUI) UI to the child instance
local alt = Screen.new(nil, nil, nil, child_session) 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') }) eq({ true, {} }, { child_session:request('nvim_eval', 'g:termfeatures') })
end) 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() it('does not query the terminal for OSC 52 support when disabled', function()
clear() clear()
exec_lua([[ exec_lua([[
@@ -3588,6 +3641,7 @@ describe('TUI', function()
local sequence = req:match('^\027P%+q([%x;]+)$') local sequence = req:match('^\027P%+q([%x;]+)$')
if sequence and vim.text.hexdecode(sequence) == 'Ms' then if sequence and vim.text.hexdecode(sequence) == 'Ms' then
_G.query = true _G.query = true
return true
end end
end, end,
}) })
@@ -3597,7 +3651,6 @@ describe('TUI', function()
screen = tt.setup_child_nvim({ screen = tt.setup_child_nvim({
'--listen', '--listen',
child_server, child_server,
-- Use --clean instead of -u NONE to load the osc52 plugin
'--clean', '--clean',
'--cmd', '--cmd',
'let g:termfeatures = #{osc52: v:false}', 'let g:termfeatures = #{osc52: v:false}',

View File

@@ -2659,7 +2659,7 @@ putglyph 1f3f4,200d,2620,fe0f 2 0,4]])
-- DA -- DA
reset(state, nil) reset(state, nil)
push('\x1b[c', vt) push('\x1b[c', vt)
expect_output('\x1b[?1;2c') expect_output('\x1b[?1;2;52c')
-- XTVERSION -- XTVERSION
reset(state, nil) reset(state, nil)