feat(terminal): surface exit code via virttext + nvim_get_chan_info #37987

Problem:
When a terminal process exits, "[Process Exited]" text is added
to the buffer contents.

Solution:
- Return `exitcode` field from `nvim_get_chan_info`.
- Show it in the default 'statusline'.
- Show exitcode as virtual text in the terminal buffer.
This commit is contained in:
Ayaan
2026-03-10 17:32:50 +05:30
committed by GitHub
parent 0cc4f53b40
commit c8693051a8
22 changed files with 132 additions and 57 deletions

View File

@@ -841,6 +841,7 @@ nvim_get_chan_info({chan}) *nvim_get_chan_info()*
• "buffer" (optional) Buffer connected to |terminal| instance.
• "client" (optional) Info about the peer (client on the other end of
the channel), as set by |nvim_set_client_info()|.
• "exitcode" (optional) Exit code of the |terminal| process.
nvim_get_color_by_name({name}) *nvim_get_color_by_name()*
Returns the 24-bit RGB value of a |nvim_get_color_map()| color name or

View File

@@ -165,6 +165,7 @@ API
• Added experimental |nvim__exec_lua_fast()| to allow remote API clients to
execute code while nvim is blocking for input.
• |vim.secure.trust()| accepts `path` for the `allow` action.
• |nvim_get_chan_info()| includes `exitcode` field for terminal buffers
BUILD
@@ -192,6 +193,7 @@ DEFAULTS
• |grt| in Normal mode maps to |vim.lsp.buf.type_definition()|
• 'shada' default now excludes "/tmp/" and "/private/" paths to reduce clutter in |:oldfiles|.
• Enabled treesitter highlighting for Markdown files
• 'statusline' shows exit code of finished terminal buffers
DIAGNOSTICS
@@ -472,6 +474,8 @@ These existing features changed their behavior.
for input and output. This matters mostly for Linux where some command lines
using "/dev/stdin" and similiar would break as these special files can be
reopened when backed by pipes but not when backed by socket pairs.
• On terminal exit, "[Process exited]" is not added to the buffer contents,
instead it is shown as virtual text and exit code is shown in statusline.
==============================================================================
REMOVED FEATURES *news-removed*

View File

@@ -81,4 +81,22 @@ function M.source_is_lua(bufnr, line1, line2)
return lang_tree:lang() == 'lua'
end
--- Returns the exit code string for the current buffer, given:
--- - Channel is attached to the current buffer
--- - Current buffer is a terminal buffer
---
--- @return string
function M.term_exitcode()
local chan_id = vim.bo.channel
if chan_id == 0 or vim.bo.buftype ~= 'terminal' then
return ''
end
local info = vim.api.nvim_get_chan_info(chan_id)
if info.exitcode and info.exitcode >= 0 then
return string.format('[Exit: %d]', info.exitcode)
end
return ''
end
return M

View File

@@ -1309,6 +1309,7 @@ function vim.api.nvim_get_autocmds(opts) end
--- - "buffer" (optional) Buffer connected to |terminal| instance.
--- - "client" (optional) Info about the peer (client on the other end of the channel), as set
--- by |nvim_set_client_info()|.
--- - "exitcode" (optional) Exit code of the |terminal| process.
---
function vim.api.nvim_get_chan_info(chan) end

View File

@@ -6964,7 +6964,7 @@ vim.wo.stc = vim.wo.statuscolumn
---
---
--- @type string
vim.o.statusline = "%<%f %h%w%m%r %=%{% &showcmdloc == 'statusline' ? '%-10.S ' : '' %}%{% exists('b:keymap_name') ? '<'..b:keymap_name..'> ' : '' %}%{% &busy > 0 ? '◐ ' : '' %}%{% luaeval('(package.loaded[''vim.diagnostic''] and #vim.diagnostic.count() ~= 0 and vim.diagnostic.status() .. '' '') or '''' ') %}%{% &ruler ? ( &rulerformat == '' ? '%-14.(%l,%c%V%) %P' : &rulerformat ) : '' %}"
vim.o.statusline = "%<%f %h%w%m%r %{% v:lua.require('vim._core.util').term_exitcode() %}%=%{% &showcmdloc == 'statusline' ? '%-10.S ' : '' %}%{% exists('b:keymap_name') ? '<'..b:keymap_name..'> ' : '' %}%{% &busy > 0 ? '◐ ' : '' %}%{% luaeval('(package.loaded[''vim.diagnostic''] and #vim.diagnostic.count() ~= 0 and vim.diagnostic.status() .. '' '') or '''' ') %}%{% &ruler ? ( &rulerformat == '' ? '%-14.(%l,%c%V%) %P' : &rulerformat ) : '' %}"
vim.o.stl = vim.o.statusline
vim.wo.statusline = vim.o.statusline
vim.wo.stl = vim.wo.statusline

View File

@@ -1719,6 +1719,7 @@ void nvim_set_client_info(uint64_t channel_id, String name, Dict version, String
/// - "buffer" (optional) Buffer connected to |terminal| instance.
/// - "client" (optional) Info about the peer (client on the other end of the channel), as set
/// by |nvim_set_client_info()|.
/// - "exitcode" (optional) Exit code of the |terminal| process.
///
Dict nvim_get_chan_info(uint64_t channel_id, Integer chan, Arena *arena, Error *err)
FUNC_API_SINCE(4)

View File

@@ -182,6 +182,7 @@ bool channel_close(uint64_t id, ChannelPart part, const char **error)
chan->stream.internal.cb = LUA_NOREF;
chan->stream.internal.closed = true;
terminal_close(&chan->term, 0);
chan->exit_status = 0;
} else {
channel_decref(chan);
}
@@ -784,8 +785,8 @@ static void channel_proc_exit_cb(Proc *proc, int status, void *data)
bool exited = (status >= 0);
if (exited && chan->on_exit.type != kCallbackNone) {
schedule_channel_event(chan);
chan->exit_status = status;
}
chan->exit_status = exited ? status : chan->exit_status;
channel_decref(chan);
}
@@ -1001,6 +1002,7 @@ Dict channel_info(uint64_t id, Arena *arena)
} else if (chan->term) {
mode_desc = "terminal";
PUT_C(info, "buffer", BUFFER_OBJ(terminal_buf(chan->term)));
PUT_C(info, "exitcode", INTEGER_OBJ(chan->exit_status));
} else {
mode_desc = "bytes";
}

View File

@@ -8774,6 +8774,7 @@ local options = {
if_true = table.concat({
'%<',
'%f %h%w%m%r ',
"%{% v:lua.require('vim._core.util').term_exitcode() %}",
'%=',
"%{% &showcmdloc == 'statusline' ? '%-10.S ' : '' %}",
"%{% exists('b:keymap_name') ? '<'..b:keymap_name..'> ' : '' %}",

View File

@@ -64,6 +64,7 @@
#include "nvim/event/multiqueue.h"
#include "nvim/event/time.h"
#include "nvim/ex_docmd.h"
#include "nvim/extmark.h"
#include "nvim/getchar.h"
#include "nvim/globals.h"
#include "nvim/grid.h"
@@ -706,13 +707,37 @@ void terminal_close(Terminal **termpp, int status)
} else if (!only_destroy) {
// Associated channel has been closed and the editor is not exiting.
// Do not call the close callback now. Wait for the user to press a key.
char msg[sizeof("\r\n[Process exited ]") + NUMBUFLEN];
char msg[sizeof("[Process exited ]") + NUMBUFLEN];
if (((Channel *)term->opts.data)->streamtype == kChannelStreamInternal) {
snprintf(msg, sizeof msg, "\r\n[Terminal closed]");
snprintf(msg, sizeof msg, "[Terminal closed]");
} else {
snprintf(msg, sizeof msg, "\r\n[Process exited %d]", status);
snprintf(msg, sizeof msg, "[Process exited %d]", status);
}
// Show the msg as virtual text instead of adding it to buffer
VirtTextChunk *chunk = xmalloc(sizeof(VirtTextChunk));
*chunk = (VirtTextChunk) { .text = xstrdup(msg), .hl_id = -1 };
DecorVirtText *virt_text = xmalloc(sizeof(DecorVirtText));
*virt_text = (DecorVirtText) {
.priority = DECOR_PRIORITY_BASE,
.pos = kVPosWinCol,
.data.virt_text = { .items = chunk, .size = 1 }
};
DecorInline decor = {
.ext = true, .data.ext = { .sh_idx = DECOR_ID_INVALID, .vt = virt_text }
};
int pos = MIN(row_to_linenr(term, term->cursor.row),
buf->b_ml.ml_line_count - 1);
extmark_set(buf, (uint32_t)-1, NULL, pos, 0, -1, 0,
decor, 0, true, false, true, false, NULL);
// Redraw statusline to show the exit code.
FOR_ALL_WINDOWS_IN_TAB(wp, curtab) {
if (wp->w_buffer == buf) {
wp->w_redr_status = true;
}
}
terminal_receive(term, msg, strlen(msg));
}
if (only_destroy) {

View File

@@ -2834,6 +2834,7 @@ describe('API', function()
mode = 'terminal',
buffer = 1,
pty = '?',
exitcode = -1,
}
local event = api.nvim_get_var('opened_event')
if not is_os('win') then
@@ -2869,6 +2870,7 @@ describe('API', function()
mode = 'terminal',
buffer = 2,
pty = '?',
exitcode = -1,
}
local actual2 = eval('nvim_get_chan_info(&channel)')
expected2.pty = actual2.pty
@@ -2878,6 +2880,7 @@ describe('API', function()
eq(1, eval('jobstop(&channel)'))
eval('jobwait([&channel], 1000)') -- Wait.
expected2.pty = (is_os('win') and '?' or '') -- pty stream was closed.
expected2.exitcode = (is_os('win') and 143 or 129)
eq(expected2, eval('nvim_get_chan_info(&channel)'))
end)
end)

View File

@@ -1370,8 +1370,8 @@ describe('jobs', function()
feed(':q<CR>')
screen:expect([[
|
[Process exited 0]^ |
^ |
[Process exited 0] |
|*4
{5:-- TERMINAL --} |
]])

View File

@@ -117,8 +117,8 @@ describe('command-line option', function()
)
feed('i:cq<CR>')
screen:expect([[
|
[Process exited 1]^ |
^ |
[Process exited 1] |
|*5
{5:-- TERMINAL --} |
]])

View File

@@ -60,7 +60,8 @@ describe('startup', function()
)
screen:expect([[
^Cannot attach UI of :terminal child to its parent. (Unset $NVIM to skip this check) |
|*2
[Process exited 1] |
|
]])
end)

View File

@@ -1,5 +1,6 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local tt = require('test.functional.testterm')
local Screen = require('test.functional.ui.screen')
local uv = vim.uv
@@ -24,6 +25,7 @@ local poke_eventloop = n.poke_eventloop
local api = n.api
local retry = t.retry
local write_file = t.write_file
local expect_exitcode = tt.expect_exitcode
describe(':recover', function()
before_each(clear)
@@ -552,12 +554,7 @@ describe('quitting swapfile dialog on startup stops TUI properly', function()
)
end)
api.nvim_chan_send(chan, 'q')
retry(nil, nil, function()
eq(
{ '', '[Process exited 1]', '' },
eval("[1, 2, '$']->map({_, lnum -> getline(lnum)->trim(' ', 2)})")
)
end)
expect_exitcode(1)
end)
it('(A)bort at second file argument with -p', function()
@@ -585,12 +582,7 @@ describe('quitting swapfile dialog on startup stops TUI properly', function()
)
end)
api.nvim_chan_send(chan, 'a')
retry(nil, nil, function()
eq(
{ '', '[Process exited 1]', '' },
eval("[1, 2, '$']->map({_, lnum -> getline(lnum)->trim(' ', 2)})")
)
end)
expect_exitcode(1)
end)
it('(Q)uit at file opened by -t', function()
@@ -626,13 +618,6 @@ describe('quitting swapfile dialog on startup stops TUI properly', function()
)
end)
api.nvim_chan_send(chan, 'q')
retry(nil, nil, function()
eq(
{ '[Process exited 1]' },
eval(
"[1, 2, '$']->map({_, lnum -> getline(lnum)->trim(' ', 2)})->filter({_, s -> !empty(trim(s))})"
)
)
end)
expect_exitcode(1)
end)
end)

View File

@@ -986,7 +986,7 @@ describe(':terminal buffer', function()
3: å̲│{1:~ }|
│{1:~ }|
[Pro│{1:~ }|
cess│{1:~ }|
│{1:~ }|
|
]])
end)
@@ -1005,10 +1005,10 @@ describe(':terminal buffer', function()
%d: TEST{MATCH: +}|
%d: TEST{MATCH: +}|
%d: TEST{MATCH: +}|
|
[Process exited 0]^ |
%d: TEST{MATCH: +}|
^[Process exited 0] |
{5:-- TERMINAL --} |
]]):format(count - 4, count - 3, count - 2, count - 1))
]]):format(count - 5, count - 4, count - 3, count - 2, count - 1))
local lines = api.nvim_buf_get_lines(0, 0, -1, true)
for i = 1, count do
eq(('%d: TEST'):format(i - 1), lines[i])
@@ -1313,6 +1313,11 @@ describe(':terminal buffer', function()
[Process exited 0] |
|*5
]])
api.nvim_buf_clear_namespace(0, -1, 0, -1)
env.screen:expect([[
^ready $ |
|*6
]])
env.buf = api.nvim_get_current_buf()
api.nvim_set_option_value('modified', false, { buf = env.buf })
end)
@@ -1329,10 +1334,10 @@ describe(':terminal buffer', function()
local chan = api.nvim_open_term(0, {})
api.nvim_chan_send(chan, 'TEST')
fn.chanclose(chan)
api.nvim_buf_clear_namespace(0, -1, 0, -1)
env.screen:expect([[
^TEST |
[Terminal closed] |
|*5
|*6
]])
env.buf = api.nvim_get_current_buf()
api.nvim_set_option_value('modified', false, { buf = env.buf })

View File

@@ -185,9 +185,9 @@ local function test_autocmd_no_crash(event, extra_tests)
]])
feed('i')
env.screen:expect([[
48: TEST |
49: TEST |
|
[Process exited 0]^ |
^[Process exited 0] |
{5:-- TERMINAL --} |
]])
feed('<CR>')

View File

@@ -43,12 +43,11 @@ describe(':edit term://*', function()
local bufcontents = {}
local winheight = api.nvim_win_get_height(0)
local buf_cont_start = rep - sb - winheight + 2
local buf_cont_start = rep - sb - winheight + 1
for i = buf_cont_start, (rep - 1) do
bufcontents[#bufcontents + 1] = ('%d: foobar'):format(i)
end
bufcontents[#bufcontents + 1] = ''
bufcontents[#bufcontents + 1] = '[Process exited 0]'
local exp_screen = '\n'
for i = 1, (winheight - 1) do

View File

@@ -861,12 +861,12 @@ describe(':terminal prints more lines than the screen height and exits', functio
("call jobstart(['%s', '10'], {'term':v:true}) | startinsert"):format(testprg('tty-test'))
)
screen:expect([[
line5 |
line6 |
line7 |
line8 |
line9 |
|
[Process exited 0]^ |
^[Process exited 0] |
{5:-- TERMINAL --} |
]])
feed('<cr>')
@@ -1096,9 +1096,8 @@ describe('pending scrollback line handling', function()
or { 'printf', ('hi\n'):rep(12) }
)
screen:expect [[
hi |*4
|
[Process exited 0]^ |
hi |*5
^[Process exited 0] |
{5:-- TERMINAL --} |
]]
assert_alive()

View File

@@ -2519,8 +2519,8 @@ describe('TUI', function()
exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigterm')]])
screen:expect(is_os('win') and { any = '%[Process exited 1%]' } or [[
Nvim: Caught deadly signal 'SIGTERM' |
|
[Process exited 1]^ |
^ |
[Process exited 1] |
|*3
{5:-- TERMINAL --} |
]])
@@ -2554,8 +2554,8 @@ describe('TUI', function()
]]
child_session:notify('nvim_exec_lua', code, {})
screen:expect([[
|
[Process exited 0]^ |
^ |
[Process exited 0] |
|*4
{5:-- TERMINAL --} |
]])
@@ -2970,8 +2970,8 @@ describe('TUI', function()
:w testF |
:q |
abc |
|
[Process exited 0]^ |
^ |
[Process exited 0] |
|
{5:-- TERMINAL --} |
]])
@@ -4237,8 +4237,8 @@ describe('TUI client', function()
exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigterm')]])
screen_client:expect(is_os('win') and { any = '%[Process exited 1%]' } or [[
Nvim: Caught deadly signal 'SIGTERM' |
|
[Process exited 1]^ |
^ |
[Process exited 1] |
|*3
{5:-- TERMINAL --} |
]])
@@ -4362,8 +4362,8 @@ describe('TUI client', function()
screen:expect([[
Remote ui failed to start: {MATCH:.*}|
|
[Process exited 1]^ |
^ |
[Process exited 1] |
|*3
{5:-- TERMINAL --} |
]])

View File

@@ -16,6 +16,8 @@ local testprg = n.testprg
local exec_lua = n.exec_lua
local api = n.api
local nvim_prog = n.nvim_prog
local retry = t.retry
local eq = t.eq
local M = {}
@@ -221,4 +223,18 @@ function M.screen_expect(screen, s)
screen:expect(s)
end
--- Asserts that the exit code of chan eventually matches the expected exit code
---
--- @param code integer expected exit code
--- @param chan? integer channel id, defaults to current buffer's channel
function M.expect_exitcode(code, chan)
chan = chan or api.nvim_get_option_value('channel', { buf = 0 }) or 0
eq(true, chan > 0, 'Expected a valid channel ID, but got: ' .. chan)
retry(nil, nil, function()
local info = api.nvim_get_chan_info(chan)
eq(code, info.exitcode)
end)
end
return M

View File

@@ -2479,7 +2479,7 @@ describe('ui/msg_puts_printf', function()
^Exモードに入ります。ー |
マルモードに戻るには "vis|
ual" と入力してください。|
: |
[Process exited 0] |
|
]])
end)

View File

@@ -1,5 +1,6 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local tt = require('test.functional.testterm')
local Screen = require('test.functional.ui.screen')
local assert_alive = n.assert_alive
@@ -14,6 +15,8 @@ local exec_lua = n.exec_lua
local eval = n.eval
local sleep = vim.uv.sleep
local pcall_err = t.pcall_err
local testprg = n.testprg
local expect_exitcode = tt.expect_exitcode
local mousemodels = { 'extend', 'popup', 'popup_setpos' }
@@ -959,6 +962,7 @@ describe('default statusline', function()
local default_statusline = table.concat({
'%<',
'%f %h%w%m%r ',
"%{% v:lua.require('vim._core.util').term_exitcode() %}",
'%=',
"%{% &showcmdloc == 'statusline' ? '%-10.S ' : '' %}",
"%{% exists('b:keymap_name') ? '<'..b:keymap_name..'> ' : '' %}",
@@ -1026,6 +1030,16 @@ describe('default statusline', function()
|
]])
end)
it('shows exit code when terminal exits #14986', function()
exec_lua("vim.o.statusline = ''")
api.nvim_set_option_value('shell', testprg('shell-test'), {})
api.nvim_set_option_value('shellcmdflag', 'EXIT', {})
api.nvim_set_option_value('shellxquote', '', {}) -- win: avoid extra quotes
command('terminal 9')
screen:expect({ any = '%[Exit: 9%]' })
expect_exitcode(9)
end)
end)
describe("'statusline' in floatwin", function()