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 an internal buffer, this is the time to display the redrawn parts
to the user. 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* Grid Events (line-based) *ui-linegrid*

View File

@@ -3598,6 +3598,18 @@ nvim_ui_pum_set_height({height}) *nvim_ui_pum_set_height()*
Parameters: ~ Parameters: ~
• {height} (`integer`) Popupmenu height, must be greater than zero. • {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()* nvim_ui_set_focus({gained}) *nvim_ui_set_focus()*
Tells the nvim server if focus was gained or lost by the GUI 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+)") local r, g, b = resp:match("\027%]4;1;rgb:(%w+)/(%w+)/(%w+)")
end, end,
}) })
io.stdout:write("\027]4;1;?\027\\") vim.api.nvim_ui_send("\027]4;1;?\027\\")
< <
*TextChanged* *TextChanged*
TextChanged After a change was made to the text in the TextChanged After a change was made to the text in the

View File

@@ -678,11 +678,11 @@ LspProgress *LspProgress*
callback = function(ev) callback = function(ev)
local value = ev.data.params.value local value = ev.data.params.value
if value.kind == 'begin' then 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 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 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
end, end,
}) })

View File

@@ -140,6 +140,8 @@ API
• Added |vim.lsp.is_enabled()| to check if a given LSP config has been enabled • Added |vim.lsp.is_enabled()| to check if a given LSP config has been enabled
by |vim.lsp.enable()|. by |vim.lsp.enable()|.
• |nvim_echo()| can set the |ui-messages| kind with which to emit the message. • |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 BUILD

View File

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

View File

@@ -839,7 +839,7 @@ do
end, end,
}) })
io.stdout:write('\027]11;?\007') vim.api.nvim_ui_send('\027]11;?\007')
end end
--- If the TUI (term_has_truecolor) was able to determine that the host --- If the TUI (term_has_truecolor) was able to determine that the host
@@ -927,7 +927,7 @@ do
local decrqss = '\027P$qm\027\\' local decrqss = '\027P$qm\027\\'
-- Reset attributes first, as other code may have set attributes. -- 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() timer:start(1000, 0, function()
-- Delete the autocommand if no response was received -- 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} --- @param win integer `window-ID`, must already belong to {tabpage}
function vim.api.nvim_tabpage_set_win(tabpage, win) end 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. --- 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, ';')) 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() timer:start(1000, 0, function()
-- Delete the autocommand if no response was received -- Delete the autocommand if no response was received

View File

@@ -14,8 +14,7 @@ function M.copy(reg)
return function(lines) return function(lines)
local s = table.concat(lines, '\n') local s = table.concat(lines, '\n')
-- The data to be written here can be quite long. -- 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_ui_send(osc52(clipboard, vim.base64.encode(s)))
vim.api.nvim_chan_send(2, osc52(clipboard, vim.base64.encode(s)))
end end
end end
@@ -34,7 +33,7 @@ function M.paste(reg)
end, end,
}) })
io.stdout:write(osc52(clipboard, '?')) vim.api.nvim_ui_send(osc52(clipboard, '?'))
local ok, res local ok, res

View File

@@ -89,7 +89,7 @@ vim.api.nvim_create_autocmd('UIEnter', {
}) })
-- Write DA1 request -- Write DA1 request
io.stdout:write('\027[c') vim.api.nvim_ui_send('\027[c')
end, 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) void remote_ui_flush_pending_data(RemoteUI *ui)
{ {
ui_flush_buf(ui, false); ui_flush_buf(ui, false);
@@ -1103,3 +1114,17 @@ void remote_ui_event(RemoteUI *ui, char *name, Array args)
free_ret: free_ret:
arena_mem_free(arena_finish(&arena)); 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. // Stop event is not exported as such, represented by EOF in the msgpack stream.
void stop(void) void stop(void)
FUNC_API_NOEXPORT; 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 // First revision of the grid protocol, used by default
void update_fg(Integer fg) 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); 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`). /// Flushes TUI grid state to a buffer (which is later flushed to the TTY by `flush_buf`).
/// ///
/// @see flush_buf /// @see flush_buf

View File

@@ -10,7 +10,9 @@ local exec = n.exec
local feed = n.feed local feed = n.feed
local api = n.api local api = n.api
local request = n.request local request = n.request
local poke_eventloop = n.poke_eventloop
local pcall_err = t.pcall_err local pcall_err = t.pcall_err
local uv = vim.uv
describe('nvim_ui_attach()', function() describe('nvim_ui_attach()', function()
before_each(function() before_each(function()
@@ -71,6 +73,72 @@ describe('nvim_ui_attach()', function()
end) end)
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() it('autocmds UIEnter/UILeave', function()
clear { args_rm = { '--headless' } } clear { args_rm = { '--headless' } }
exec([[ exec([[

View File

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