commit 586b1b2d9bcfdbc2a58a7a9c90e8f90e638173e7
parent 5d8e870c1178e8e8b6bb9bd5cd17682eb1edd825
Author: Gregory Anders <greg@gpanders.com>
Date: Fri, 22 Aug 2025 15:05:43 -0500
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.
Diffstat:
16 files changed, 161 insertions(+), 13 deletions(-)
diff --git a/runtime/doc/api-ui-events.txt b/runtime/doc/api-ui-events.txt
@@ -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*
diff --git a/runtime/doc/api.txt b/runtime/doc/api.txt
@@ -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
diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt
@@ -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
diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt
@@ -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,
})
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -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
diff --git a/runtime/lua/tohtml.lua b/runtime/lua/tohtml.lua
@@ -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
diff --git a/runtime/lua/vim/_defaults.lua b/runtime/lua/vim/_defaults.lua
@@ -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
diff --git a/runtime/lua/vim/_meta/api.lua b/runtime/lua/vim/_meta/api.lua
@@ -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.
---
---
diff --git a/runtime/lua/vim/termcap.lua b/runtime/lua/vim/termcap.lua
@@ -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
diff --git a/runtime/lua/vim/ui/clipboard/osc52.lua b/runtime/lua/vim/ui/clipboard/osc52.lua
@@ -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
diff --git a/runtime/plugin/osc52.lua b/runtime/plugin/osc52.lua
@@ -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,
})
diff --git a/src/nvim/api/ui.c b/src/nvim/api/ui.c
@@ -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);
+}
diff --git a/src/nvim/api/ui_events.in.h b/src/nvim/api/ui_events.in.h
@@ -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)
diff --git a/src/nvim/tui/tui.c b/src/nvim/tui/tui.c
@@ -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
diff --git a/test/functional/api/ui_spec.lua b/test/functional/api/ui_spec.lua
@@ -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([[
diff --git a/test/functional/ui/screen.lua b/test/functional/ui/screen.lua
@@ -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)