From 586b1b2d9bcfdbc2a58a7a9c90e8f90e638173e7 Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Fri, 22 Aug 2025 15:05:43 -0500 Subject: [PATCH] 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. --- runtime/doc/api-ui-events.txt | 4 ++ runtime/doc/api.txt | 12 +++++ runtime/doc/autocmd.txt | 2 +- runtime/doc/lsp.txt | 6 +-- runtime/doc/news.txt | 2 + runtime/lua/tohtml.lua | 4 +- runtime/lua/vim/_defaults.lua | 4 +- runtime/lua/vim/_meta/api.lua | 8 +++ runtime/lua/vim/termcap.lua | 2 +- runtime/lua/vim/ui/clipboard/osc52.lua | 5 +- runtime/plugin/osc52.lua | 2 +- src/nvim/api/ui.c | 25 ++++++++++ src/nvim/api/ui_events.in.h | 2 + src/nvim/tui/tui.c | 13 +++++ test/functional/api/ui_spec.lua | 68 ++++++++++++++++++++++++++ test/functional/ui/screen.lua | 15 ++++++ 16 files changed, 161 insertions(+), 13 deletions(-) diff --git a/runtime/doc/api-ui-events.txt b/runtime/doc/api-ui-events.txt index 05c4bb181b..5a70b183be 100644 --- 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 index ee896da84b..1a041cf5ad 100644 --- 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 index e21b8a21e5..41a7e72eba 100644 --- 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 index 6b9f81fd45..3c902819a9 100644 --- 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 index e57b93265c..76a31c6baa 100644 --- 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 index 019fab6286..4deef7cd60 100644 --- 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 index df4695af50..d240bb47e9 100644 --- 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 index 26b51b7dbb..43a3fd73a7 100644 --- 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 index 2789aacb90..887fd5b58f 100644 --- 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 index a49b690965..0f6016c6e8 100644 --- 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 index 3ca7c5e3c0..370dafbb4e 100644 --- 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 index c607c40351..16c478e215 100644 --- 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 index af4f5eaf27..010c452044 100644 --- 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 index f69be88bd1..567b85eb2e 100644 --- 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 index 5976610af1..190489bac5 100644 --- 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 index 0efbfa2512..355a385b78 100644 --- 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 --- @field private _attr_table table --- @field private _hl_info 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)