feat(tui): add nvim_ui_send (#35406)

This function allows the Nvim core to write arbitrary data to a TTY
connected to a UI's stdout.
This commit is contained in:
Gregory Anders
2025-08-22 15:05:43 -05:00
committed by GitHub
parent 5d8e870c11
commit 586b1b2d9b
16 changed files with 161 additions and 13 deletions

View File

@@ -276,6 +276,10 @@ the editor.
to an internal buffer, this is the time to display the redrawn parts
to the user.
["ui_send", content] ~
Write {content} to the connected TTY. Only UIs that have the
"stdout_tty" |ui-option| set will receive this event.
==============================================================================
Grid Events (line-based) *ui-linegrid*

View File

@@ -3598,6 +3598,18 @@ nvim_ui_pum_set_height({height}) *nvim_ui_pum_set_height()*
Parameters: ~
• {height} (`integer`) Popupmenu height, must be greater than zero.
nvim_ui_send({content}) *nvim_ui_send()*
WARNING: This feature is experimental/unstable.
Sends arbitrary data to a UI.
This sends a "ui_send" event to any UI that has the "stdout_tty"
|ui-option| set. UIs are expected to write the received data to a
connected TTY if one exists.
Parameters: ~
• {content} (`string`) Content to write to the TTY
nvim_ui_set_focus({gained}) *nvim_ui_set_focus()*
Tells the nvim server if focus was gained or lost by the GUI

View File

@@ -1067,7 +1067,7 @@ TermResponse When Nvim receives a DA1, OSC, DCS, or APC response from
local r, g, b = resp:match("\027%]4;1;rgb:(%w+)/(%w+)/(%w+)")
end,
})
io.stdout:write("\027]4;1;?\027\\")
vim.api.nvim_ui_send("\027]4;1;?\027\\")
<
*TextChanged*
TextChanged After a change was made to the text in the

View File

@@ -678,11 +678,11 @@ LspProgress *LspProgress*
callback = function(ev)
local value = ev.data.params.value
if value.kind == 'begin' then
io.stdout:write('\027]9;4;1;0\027\\')
vim.api.nvim_ui_send('\027]9;4;1;0\027\\')
elseif value.kind == 'end' then
io.stdout:write('\027]9;4;0\027\\')
vim.api.nvim_ui_send('\027]9;4;0\027\\')
elseif value.kind == 'report' then
io.stdout:write(string.format('\027]9;4;1;%d\027\\', value.percentage or 0))
vim.api.nvim_ui_send(string.format('\027]9;4;1;%d\027\\', value.percentage or 0))
end
end,
})

View File

@@ -140,6 +140,8 @@ API
• Added |vim.lsp.is_enabled()| to check if a given LSP config has been enabled
by |vim.lsp.enable()|.
• |nvim_echo()| can set the |ui-messages| kind with which to emit the message.
• |nvim_ui_send()| writes arbitrary data to a UI's stdout. Use this to write
escape sequences to the terminal when Nvim is running in the |TUI|.
BUILD

View File

@@ -211,9 +211,9 @@ local function try_query_terminal_color(color)
end,
})
if type(color) == 'number' then
io.stdout:write(('\027]%s;%s;?\027\\'):format(parameter, color))
vim.api.nvim_ui_send(('\027]%s;%s;?\027\\'):format(parameter, color))
else
io.stdout:write(('\027]%s;?\027\\'):format(parameter))
vim.api.nvim_ui_send(('\027]%s;?\027\\'):format(parameter))
end
vim.wait(100, function()
return hex and true or false

View File

@@ -839,7 +839,7 @@ do
end,
})
io.stdout:write('\027]11;?\007')
vim.api.nvim_ui_send('\027]11;?\007')
end
--- If the TUI (term_has_truecolor) was able to determine that the host
@@ -927,7 +927,7 @@ do
local decrqss = '\027P$qm\027\\'
-- Reset attributes first, as other code may have set attributes.
io.stdout:write(string.format('\027[0m\027[48;2;%d;%d;%dm%s', r, g, b, decrqss))
vim.api.nvim_ui_send(string.format('\027[0m\027[48;2;%d;%d;%dm%s', r, g, b, decrqss))
timer:start(1000, 0, function()
-- Delete the autocommand if no response was received

View File

@@ -2340,6 +2340,14 @@ function vim.api.nvim_tabpage_set_var(tabpage, name, value) end
--- @param win integer `window-ID`, must already belong to {tabpage}
function vim.api.nvim_tabpage_set_win(tabpage, win) end
--- Sends arbitrary data to a UI.
---
--- This sends a "ui_send" event to any UI that has the "stdout_tty" `ui-option` set. UIs are
--- expected to write the received data to a connected TTY if one exists.
---
--- @param content string Content to write to the TTY
function vim.api.nvim_ui_send(content) end
--- Calls a function with window as temporary current window.
---
---

View File

@@ -71,7 +71,7 @@ function M.query(caps, cb)
local query = string.format('\027P+q%s\027\\', table.concat(encoded, ';'))
io.stdout:write(query)
vim.api.nvim_ui_send(query)
timer:start(1000, 0, function()
-- Delete the autocommand if no response was received

View File

@@ -14,8 +14,7 @@ function M.copy(reg)
return function(lines)
local s = table.concat(lines, '\n')
-- The data to be written here can be quite long.
-- Use nvim_chan_send() as io.stdout:write() doesn't handle EAGAIN. #26688
vim.api.nvim_chan_send(2, osc52(clipboard, vim.base64.encode(s)))
vim.api.nvim_ui_send(osc52(clipboard, vim.base64.encode(s)))
end
end
@@ -34,7 +33,7 @@ function M.paste(reg)
end,
})
io.stdout:write(osc52(clipboard, '?'))
vim.api.nvim_ui_send(osc52(clipboard, '?'))
local ok, res

View File

@@ -89,7 +89,7 @@ vim.api.nvim_create_autocmd('UIEnter', {
})
-- Write DA1 request
io.stdout:write('\027[c')
vim.api.nvim_ui_send('\027[c')
end,
})

View File

@@ -1001,6 +1001,17 @@ void remote_ui_flush(RemoteUI *ui)
}
}
void remote_ui_ui_send(RemoteUI *ui, String content)
{
if (!ui->stdout_tty) {
return;
}
MAXSIZE_TEMP_ARRAY(args, 1);
ADD_C(args, STRING_OBJ(content));
push_call(ui, "ui_send", args);
}
void remote_ui_flush_pending_data(RemoteUI *ui)
{
ui_flush_buf(ui, false);
@@ -1103,3 +1114,17 @@ void remote_ui_event(RemoteUI *ui, char *name, Array args)
free_ret:
arena_mem_free(arena_finish(&arena));
}
/// Sends arbitrary data to a UI.
///
/// This sends a "ui_send" event to any UI that has the "stdout_tty" |ui-option| set. UIs are
/// expected to write the received data to a connected TTY if one exists.
///
/// @param channel_id
/// @param content Content to write to the TTY
/// @param[out] err Error details, if any
void nvim_ui_send(uint64_t channel_id, String content, Error *err)
FUNC_API_SINCE(14)
{
ui_call_ui_send(content);
}

View File

@@ -46,6 +46,8 @@ void chdir(String path)
// Stop event is not exported as such, represented by EOF in the msgpack stream.
void stop(void)
FUNC_API_NOEXPORT;
void ui_send(String content)
FUNC_API_SINCE(14) FUNC_API_REMOTE_IMPL;
// First revision of the grid protocol, used by default
void update_fg(Integer fg)

View File

@@ -1533,6 +1533,19 @@ void tui_default_colors_set(TUIData *tui, Integer rgb_fg, Integer rgb_bg, Intege
invalidate(tui, 0, tui->grid.height, 0, tui->grid.width);
}
/// Writes directly to the TTY, bypassing the buffer.
void tui_ui_send(TUIData *tui, String content)
FUNC_ATTR_NONNULL_ALL
{
uv_write_t req;
uv_buf_t buf = { .base = content.data, .len = UV_BUF_LEN(content.size) };
int ret = uv_write(&req, (uv_stream_t *)&tui->output_handle, &buf, 1, NULL);
if (ret) {
ELOG("uv_write failed: %s", uv_strerror(ret));
}
uv_run(&tui->write_loop, UV_RUN_DEFAULT);
}
/// Flushes TUI grid state to a buffer (which is later flushed to the TTY by `flush_buf`).
///
/// @see flush_buf

View File

@@ -10,7 +10,9 @@ local exec = n.exec
local feed = n.feed
local api = n.api
local request = n.request
local poke_eventloop = n.poke_eventloop
local pcall_err = t.pcall_err
local uv = vim.uv
describe('nvim_ui_attach()', function()
before_each(function()
@@ -71,6 +73,72 @@ describe('nvim_ui_attach()', function()
end)
end)
describe('nvim_ui_send', function()
before_each(function()
clear()
end)
it('works with stdout_tty', function()
local fds = assert(uv.pipe())
local read_pipe = assert(uv.new_pipe())
read_pipe:open(fds.read)
local read_data = {}
read_pipe:read_start(function(err, data)
assert(not err, err)
if data then
table.insert(read_data, data)
end
end)
local screen = Screen.new(50, 10, { stdout_tty = true })
screen:set_stdout(fds.write)
api.nvim_ui_send('Hello world')
poke_eventloop()
screen:expect([[
^ |
{1:~ }|*8
|
]])
eq('Hello world', table.concat(read_data))
end)
it('ignores ui_send event for UIs without stdout_tty', function()
local fds = assert(uv.pipe())
local read_pipe = assert(uv.new_pipe())
read_pipe:open(fds.read)
local read_data = {}
read_pipe:read_start(function(err, data)
assert(not err, err)
if data then
table.insert(read_data, data)
end
end)
local screen = Screen.new(50, 10)
screen:set_stdout(fds.write)
api.nvim_ui_send('Hello world')
poke_eventloop()
screen:expect([[
^ |
{1:~ }|*8
|
]])
eq('', table.concat(read_data))
end)
end)
it('autocmds UIEnter/UILeave', function()
clear { args_rm = { '--headless' } }
exec([[

View File

@@ -48,6 +48,7 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local busted = require('busted')
local uv = vim.uv
local deepcopy = vim.deepcopy
local shallowcopy = t.shallowcopy
@@ -89,6 +90,7 @@ end
--- @field private _grid_win_extmarks table<integer,table>
--- @field private _attr_table table<integer,table>
--- @field private _hl_info table<integer,table>
--- @field private _stdout uv.uv_pipe_t?
local Screen = {}
Screen.__index = Screen
@@ -235,6 +237,7 @@ function Screen.new(width, height, options, session)
col = 1,
},
_busy = false,
_stdout = nil,
}, Screen)
local function ui(method, ...)
@@ -278,6 +281,12 @@ function Screen:set_rgb_cterm(val)
self._rgb_cterm = val
end
--- @param fd number
function Screen:set_stdout(fd)
self._stdout = assert(uv.new_pipe())
self._stdout:open(fd)
end
--- @param session? test.Session
function Screen:attach(session)
session = session or get_session()
@@ -1416,6 +1425,12 @@ function Screen:_handle_msg_history_show(entries, prev_cmd)
self.msg_history = { entries, prev_cmd }
end
function Screen:_handle_ui_send(content)
if self._stdout then
self._stdout:write(content)
end
end
function Screen:_clear_block(grid, top, bot, left, right)
for i = top, bot do
self:_clear_row_section(grid, i, left, right)