commit 6bc0b8ae870c80e1dc9004e07436c46259854cc4
parent 9c5ade9212d897db590aeb6a2a5340e73ffe44d0
Author: zeertzjq <zeertzjq@outlook.com>
Date: Fri, 13 Feb 2026 21:49:08 +0800
feat(terminal): detect suspended PTY process (#37845)
Problem: Terminal doesn't detect if the PTY process is suspended or
offer a convenient way for the user to resume the process.
Solution: Detect suspended PTY process on SIGCHLD and show virtual text
"[Process suspended]" at the bottom-left. Resume the process
when the user presses a key.
Diffstat:
11 files changed, 216 insertions(+), 15 deletions(-)
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -382,6 +382,8 @@ TERMINAL
• |nvim_open_term()| can be called with a non-empty buffer. The buffer
contents are piped to the PTY and displayed as terminal output.
• CSI 3 J (the sequence to clear terminal scrollback) is now supported.
+• A suspended PTY process is now indicated by "[Process suspended]" at the
+ bottom-left of the buffer and can be resumed by pressing a key.
TREESITTER
diff --git a/src/nvim/api/vim.c b/src/nvim/api/vim.c
@@ -1144,6 +1144,7 @@ Integer nvim_open_term(Buffer buffer, Dict(open_term) *opts, Error *err)
.height = (uint16_t)curwin->w_view_height,
.write_cb = term_write,
.resize_cb = term_resize,
+ .resume_cb = term_resume,
.close_cb = term_close,
.force_crlf = GET_BOOL_OR_TRUE(opts, open_term, force_crlf),
};
@@ -1194,6 +1195,10 @@ static void term_resize(uint16_t width, uint16_t height, void *data)
// TODO(bfredl): Lua callback
}
+static void term_resume(void *data)
+{
+}
+
static void term_close(void *data)
{
Channel *chan = data;
diff --git a/src/nvim/channel.c b/src/nvim/channel.c
@@ -393,6 +393,7 @@ Channel *channel_job_start(char **argv, const char *exepath, CallbackReader on_s
proc->argv = argv;
proc->exepath = exepath;
proc->cb = channel_proc_exit_cb;
+ proc->state_cb = channel_proc_state_cb;
proc->events = chan->events;
proc->detach = detach;
proc->cwd = cwd;
@@ -762,6 +763,14 @@ static void channel_proc_exit_cb(Proc *proc, int status, void *data)
channel_decref(chan);
}
+static void channel_proc_state_cb(Proc *proc, bool suspended, void *data)
+{
+ Channel *chan = data;
+ if (chan->term) {
+ terminal_set_state(chan->term, suspended);
+ }
+}
+
static void channel_callback_call(Channel *chan, CallbackReader *reader)
{
Callback *cb;
@@ -812,6 +821,7 @@ void channel_terminal_alloc(buf_T *buf, Channel *chan)
.height = chan->stream.pty.height,
.write_cb = term_write,
.resize_cb = term_resize,
+ .resume_cb = term_resume,
.close_cb = term_close,
.force_crlf = false,
};
@@ -839,6 +849,14 @@ static void term_resize(uint16_t width, uint16_t height, void *data)
pty_proc_resize(&chan->stream.pty, width, height);
}
+static void term_resume(void *data)
+{
+#ifdef UNIX
+ Channel *chan = data;
+ pty_proc_resume(&chan->stream.pty);
+#endif
+}
+
static inline void term_delayed_free(void **argv)
{
Channel *chan = argv[0];
diff --git a/src/nvim/drawscreen.c b/src/nvim/drawscreen.c
@@ -1441,6 +1441,17 @@ static void win_update(win_T *wp)
decor_providers_invoke_win(wp);
+ if (buf->terminal && terminal_suspended(buf->terminal)) {
+ static VirtTextChunk chunk = { .text = "[Process suspended]", .hl_id = -1 };
+ static DecorVirtText virt_text = {
+ .priority = DECOR_PRIORITY_BASE,
+ .pos = kVPosWinCol,
+ .data.virt_text = { .items = &chunk, .size = 1 },
+ };
+ decor_range_add_virt(&decor_state, buf->b_ml.ml_line_count - 1, 0,
+ buf->b_ml.ml_line_count - 1, 0, &virt_text, false);
+ }
+
FOR_ALL_WINDOWS_IN_TAB(win, curtab) {
if (win->w_buffer == wp->w_buffer && win_redraw_signcols(win)) {
changed_line_abv_curs_win(win);
diff --git a/src/nvim/event/defs.h b/src/nvim/event/defs.h
@@ -152,6 +152,7 @@ typedef enum {
/// OS process
typedef struct proc Proc;
typedef void (*proc_exit_cb)(Proc *proc, int status, void *data);
+typedef void (*proc_state_cb)(Proc *proc, bool suspended, void *data);
typedef void (*internal_proc_cb)(Proc *proc);
struct proc {
@@ -169,6 +170,7 @@ struct proc {
RStream out, err;
/// Exit handler. If set, user must call proc_free().
proc_exit_cb cb;
+ proc_state_cb state_cb;
internal_proc_cb internal_exit_cb, internal_close_cb;
bool closed, detach, overlapped, fwd_err;
MultiQueue *events;
diff --git a/src/nvim/event/proc.h b/src/nvim/event/proc.h
@@ -25,6 +25,7 @@ static inline Proc proc_init(Loop *loop, ProcType type, void *data)
.out = { .s.closed = false, .s.fd = STDOUT_FILENO },
.err = { .s.closed = false, .s.fd = STDERR_FILENO },
.cb = NULL,
+ .state_cb = NULL,
.closed = false,
.internal_close_cb = NULL,
.internal_exit_cb = NULL,
diff --git a/src/nvim/os/pty_proc_unix.c b/src/nvim/os/pty_proc_unix.c
@@ -239,6 +239,11 @@ void pty_proc_resize(PtyProc *ptyproc, uint16_t width, uint16_t height)
ioctl(ptyproc->tty_fd, TIOCSWINSZ, &ptyproc->winsize);
}
+void pty_proc_resume(PtyProc *ptyproc)
+{
+ kill(((Proc *)ptyproc)->pid, SIGCONT);
+}
+
void pty_proc_close(PtyProc *ptyproc)
FUNC_ATTR_NONNULL_ALL
{
@@ -397,13 +402,22 @@ static void chld_handler(uv_signal_t *handle, int signum)
for (size_t i = 0; i < kv_size(loop->children); i++) {
Proc *proc = kv_A(loop->children, i);
do {
- pid = waitpid(proc->pid, &stat, WNOHANG);
+ pid = waitpid(proc->pid, &stat, WNOHANG|WUNTRACED|WCONTINUED);
} while (pid < 0 && errno == EINTR);
if (pid <= 0) {
continue;
}
+ if (WIFSTOPPED(stat)) {
+ proc->state_cb(proc, true, proc->data);
+ continue;
+ }
+ if (WIFCONTINUED(stat)) {
+ proc->state_cb(proc, false, proc->data);
+ continue;
+ }
+
if (WIFEXITED(stat)) {
proc->status = WEXITSTATUS(stat);
} else if (WIFSIGNALED(stat)) {
diff --git a/src/nvim/terminal.c b/src/nvim/terminal.c
@@ -174,6 +174,8 @@ struct terminal {
bool in_altscreen;
// program exited
bool closed;
+ // program suspended
+ bool suspended;
// when true, the terminal's destruction is already enqueued.
bool destroy;
@@ -672,7 +674,6 @@ void terminal_close(Terminal **termpp, int status)
// only need to call the close callback to clean up the terminal object.
only_destroy = true;
} else {
- term->forward_mouse = false;
// flush any pending changes to the buffer
if (!exiting) {
block_autocmds();
@@ -726,7 +727,33 @@ void terminal_close(Terminal **termpp, int status)
}
}
+static void terminal_state_change_event(void **argv)
+{
+ handle_T buf_handle = (handle_T)(intptr_t)argv[0];
+ buf_T *buf = handle_get_buffer(buf_handle);
+ if (buf && buf->terminal) {
+ // Don't change the actual terminal content to indicate the suspended state here,
+ // as unlike the process exit case the change needs to be reversed on resume.
+ // Instead, the code in win_update() will add a "[Process suspended]" virtual text
+ // at the botton-left of the buffer.
+ redraw_buf_line_later(buf, buf->b_ml.ml_line_count, false);
+ }
+}
+
+/// Updates the suspended state of the terminal program.
+void terminal_set_state(Terminal *term, bool suspended)
+ FUNC_ATTR_NONNULL_ALL
+{
+ if (term->suspended != suspended) {
+ // Trigger a main loop iteration to redraw the buffer.
+ multiqueue_put(main_loop.events, terminal_state_change_event,
+ (void *)(intptr_t)term->buf_handle);
+ }
+ term->suspended = suspended;
+}
+
void terminal_check_size(Terminal *term)
+ FUNC_ATTR_NONNULL_ALL
{
if (term->closed) {
return;
@@ -951,9 +978,14 @@ static void terminal_check_cursor(void)
if (topline != curwin->w_topline) {
set_topline(curwin, topline);
}
- // Nudge cursor when returning to normal-mode.
- int off = is_focused(term) ? 0 : (curwin->w_p_rl ? 1 : -1);
- coladvance(curwin, MAX(0, term->cursor.col + off));
+ if (term->suspended) {
+ // If the terminal process is suspended, keep cursor at the bottom-left corner.
+ curwin->w_cursor = (pos_T){ .lnum = curbuf->b_ml.ml_line_count };
+ } else {
+ // Nudge cursor when returning to normal-mode.
+ int off = is_focused(term) ? 0 : (curwin->w_p_rl ? 1 : -1);
+ coladvance(curwin, MAX(0, term->cursor.col + off));
+ }
}
static bool terminal_check_focus(TerminalState *const s)
@@ -1129,6 +1161,13 @@ static int terminal_execute(VimState *state, int key)
s->got_bsl = true;
break;
}
+ if (s->term->suspended) {
+ s->term->opts.resume_cb(s->term->opts.data);
+ // XXX: detecting continued process via waitpid() on SIGCHLD doesn't always work
+ // (e.g. on macOS), so also consider it continued after sending SIGCONT.
+ terminal_set_state(s->term, false);
+ break;
+ }
if (s->term->closed) {
s->close = true;
return 0;
@@ -1393,15 +1432,23 @@ void terminal_get_line_attributes(Terminal *term, win_T *wp, int linenr, int *te
}
Buffer terminal_buf(const Terminal *term)
+ FUNC_ATTR_NONNULL_ALL
{
return term->buf_handle;
}
bool terminal_running(const Terminal *term)
+ FUNC_ATTR_NONNULL_ALL
{
return !term->closed;
}
+bool terminal_suspended(const Terminal *term)
+ FUNC_ATTR_NONNULL_ALL
+{
+ return term->suspended;
+}
+
void terminal_notify_theme(Terminal *term, bool dark)
FUNC_ATTR_NONNULL_ALL
{
@@ -2063,7 +2110,8 @@ static bool send_mouse_event(Terminal *term, int c)
}
int offset;
- if (term->forward_mouse && mouse_win->w_buffer->terminal == term && row >= 0
+ if (!term->suspended && !term->closed
+ && term->forward_mouse && mouse_win->w_buffer->terminal == term && row >= 0
&& (grid > 1 || row + mouse_win->w_winbar_height < mouse_win->w_height)
&& col >= (offset = win_col_off(mouse_win))
&& (grid > 1 || col < mouse_win->w_width)) {
diff --git a/src/nvim/terminal.h b/src/nvim/terminal.h
@@ -9,6 +9,7 @@
typedef void (*terminal_write_cb)(const char *buffer, size_t size, void *data);
typedef void (*terminal_resize_cb)(uint16_t width, uint16_t height, void *data);
+typedef void (*terminal_resume_cb)(void *data);
typedef void (*terminal_close_cb)(void *data);
typedef struct {
@@ -16,6 +17,7 @@ typedef struct {
uint16_t width, height;
terminal_write_cb write_cb;
terminal_resize_cb resize_cb;
+ terminal_resume_cb resume_cb;
terminal_close_cb close_cb;
bool force_crlf;
} TerminalOptions;
diff --git a/test/functional/terminal/buffer_spec.lua b/test/functional/terminal/buffer_spec.lua
@@ -450,6 +450,105 @@ describe(':terminal buffer', function()
]])
assert_alive()
end)
+
+ describe('handles suspended PTY process', function()
+ if skip(is_os('win'), 'N/A for Windows') then
+ return
+ end
+
+ --- @param external_resume boolean
+ local function test_term_process_suspend_resume(external_resume)
+ command('set mousemodel=extend')
+ local pid = eval('jobpid(&channel)')
+ vim.uv.kill(pid, 'sigstop')
+ screen:expect([[
+ tty ready |
+ |*4
+ ^[Process suspended] |
+ {5:-- TERMINAL --} |
+ ]])
+ command('set laststatus=0 | botright vsplit')
+ screen:expect([[
+ tty ready │tty ready |
+ │ |*4
+ [Process suspended] │^[Process suspended] |
+ {5:-- TERMINAL --} |
+ ]])
+ -- Resize is detected by the process on resume.
+ if external_resume then
+ vim.uv.kill(pid, 'sigcont')
+ else
+ feed('a')
+ end
+ screen:expect([[
+ tty ready │tty ready |
+ rows: 6, cols: 25 │rows: 6, cols: 25 |
+ │^ |
+ │ |*3
+ {5:-- TERMINAL --} |
+ ]])
+ tt.enable_mouse()
+ tt.feed_data('mouse enabled\n\n\n\n')
+ screen:expect([[
+ rows: 6, cols: 25 │rows: 6, cols: 25 |
+ mouse enabled │mouse enabled |
+ │ |*3
+ │^ |
+ {5:-- TERMINAL --} |
+ ]])
+ api.nvim_input_mouse('right', 'press', '', 0, 0, 25)
+ screen:expect({ any = vim.pesc('"!!^') })
+ api.nvim_input_mouse('right', 'release', '', 0, 0, 25)
+ screen:expect({ any = vim.pesc('#!!^') })
+ vim.uv.kill(pid, 'sigstop')
+ local s1 = [[
+ rows: 6, cols: 25 │rows: 6, cols: 25 |
+ mouse enabled │mouse enabled |
+ │ |*3
+ [Process suspended] │^[Process suspended] |
+ {5:-- TERMINAL --} |
+ ]]
+ screen:expect(s1)
+ -- Mouse isn't forwarded when process is suspended.
+ api.nvim_input_mouse('right', 'press', '', 0, 1, 27)
+ api.nvim_input_mouse('right', 'release', '', 0, 1, 27)
+ screen:expect([[
+ rows: 6, cols: 25 │rows: 6, cols: 25 |
+ mo{108:use enabled} │mo^u{108:se enabled} |
+ {108: } │{108: } |*3
+ [Process suspended] │[Process suspended] |
+ {5:-- VISUAL --} |
+ ]])
+ feed('<Esc>i')
+ screen:expect(s1)
+ if external_resume then
+ vim.uv.kill(pid, 'sigcont')
+ else
+ feed('a')
+ end
+ screen:expect([[
+ rows: 6, cols: 25 │rows: 6, cols: 25 |
+ mouse enabled │mouse enabled |
+ │ |*3
+ #!! │ #!!^ |
+ {5:-- TERMINAL --} |
+ ]])
+ -- Mouse is forwarded after process is resumed.
+ api.nvim_input_mouse('right', 'press', '', 0, 0, 28)
+ screen:expect({ any = vim.pesc('"$!^') })
+ api.nvim_input_mouse('right', 'release', '', 0, 0, 28)
+ screen:expect({ any = vim.pesc('#$!^') })
+ end
+
+ it('resumed by an external signal', function()
+ skip(is_os('mac'), 'FIXME: does not work on macOS')
+ test_term_process_suspend_resume(true)
+ end)
+
+ it('resumed by pressing a key', function()
+ test_term_process_suspend_resume(false)
+ end)
+ end)
end)
describe(':terminal buffer', function()
diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua
@@ -100,11 +100,11 @@ describe('TUI', function()
]])
else -- resuming works on other platforms
screen:expect([[
- ^ |
|*5
+ ^[Process suspended] |
{5:-- TERMINAL --} |
]])
- exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
+ n.feed('<Space>')
screen:expect(s0)
end
feed_data(':')
@@ -4409,7 +4409,6 @@ describe('TUI client', function()
it('suspend/resume works with multiple clients', function()
t.skip(is_os('win'), 'N/A for Windows')
local server_super, screen_server, screen_client = start_tui_and_remote_client()
- local server_super_exec_lua = tt.make_lua_executor(server_super)
local screen_normal = [[
Hello, Worl^d |
@@ -4419,8 +4418,8 @@ describe('TUI client', function()
{5:-- TERMINAL --} |
]]
local screen_suspended = [[
- ^ |
|*5
+ ^[Process suspended] |
{5:-- TERMINAL --} |
]]
@@ -4433,12 +4432,12 @@ describe('TUI client', function()
screen_server:expect({ grid = screen_suspended })
-- Resume the remote client.
- exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
+ n.feed('<Space>')
screen_client:expect({ grid = screen_normal })
screen_server:expect({ grid = screen_suspended, unchanged = true })
-- Resume the embedding client.
- server_super_exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
+ server_super:request('nvim_input', '<Space>')
screen_server:expect({ grid = screen_normal })
screen_client:expect({ grid = screen_normal, unchanged = true })
@@ -4448,7 +4447,7 @@ describe('TUI client', function()
screen_server:expect({ grid = screen_suspended })
-- Resume the remote client.
- exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
+ n.feed('<Space>')
screen_client:expect({ grid = screen_normal })
screen_server:expect({ grid = screen_suspended, unchanged = true })
@@ -4458,12 +4457,12 @@ describe('TUI client', function()
screen_server:expect({ grid = screen_suspended, unchanged = true })
-- Resume the embedding client.
- server_super_exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
+ server_super:request('nvim_input', '<Space>')
screen_server:expect({ grid = screen_normal })
screen_client:expect({ grid = screen_suspended, unchanged = true })
-- Resume the remote client.
- exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
+ n.feed('<Space>')
screen_client:expect({ grid = screen_normal })
screen_server:expect({ grid = screen_normal, unchanged = true })