commit e254688016a40366e33058d54628935b14bdcb24
parent 6ad73421cbfc42d63e8e2d3522ef1e6b9ed48855
Author: zeertzjq <zeertzjq@outlook.com>
Date: Mon, 9 Feb 2026 19:28:00 +0800
fix(terminal): handle ED 3 (clear scrollback) properly (#21412)
Problem: Terminal doesn't handle ED 3 (clear scrollback) properly.
Solution: Add vterm callback for sb_clear().
Also fix another problem that scrollback lines may be duplicated when
pushing to scrollback immediately after reducing window height, as can
be seen in the changes to test/functional/terminal/window_spec.lua.
Diffstat:
5 files changed, 308 insertions(+), 60 deletions(-)
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -378,6 +378,7 @@ 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.
TREESITTER
@@ -433,9 +434,8 @@ These existing features changed their behavior.
• 'scrollback' maximum value increased from 100000 to 1000000
• |matchfuzzy()| and |matchfuzzypos()| use an improved fuzzy matching algorithm
(same as fzy).
-- Windows: Paths like "\Windows" and "/Windows" are now considered to be
+• Windows: Paths like "\Windows" and "/Windows" are now considered to be
absolute paths (to the current drive) and no longer relative.
-
• When 'shelltemp' is off, shell commands now use `pipe()` and not `socketpair()`
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
diff --git a/src/nvim/terminal.c b/src/nvim/terminal.c
@@ -155,9 +155,7 @@ struct terminal {
size_t sb_current; // Lines stored in sb_buffer.
size_t sb_size; // Capacity of sb_buffer.
// "virtual index" that points to the first sb_buffer row that we need to
- // push to the terminal buffer when refreshing the scrollback. When negative,
- // it actually points to entries that are no longer in sb_buffer (because the
- // window height has increased) and must be deleted from the terminal buffer
+ // push to the terminal buffer when refreshing the scrollback.
int sb_pending;
size_t sb_deleted; // Lines deleted from sb_buffer.
size_t sb_deleted_last; // Value of sb_deleted on last refresh_scrollback()
@@ -171,6 +169,7 @@ struct terminal {
// refresh_timer_cb may be called after the buffer was freed, and there's
// no way to know if the memory was reused.
handle_T buf_handle;
+ bool in_altscreen;
// program exited
bool closed;
// when true, the terminal's destruction is already enqueued.
@@ -216,6 +215,7 @@ static VTermScreenCallbacks vterm_screen_callbacks = {
.theme = term_theme,
.sb_pushline = term_sb_push, // Called before a line goes offscreen.
.sb_popline = term_sb_pop,
+ .sb_clear = term_sb_clear,
};
static VTermSelectionCallbacks vterm_selection_callbacks = {
@@ -1467,6 +1467,7 @@ static int term_settermprop(VTermProp prop, VTermValue *val, void *data)
switch (prop) {
case VTERM_PROP_ALTSCREEN:
+ term->in_altscreen = val->boolean;
break;
case VTERM_PROP_CURSORVISIBLE:
@@ -1613,7 +1614,7 @@ static int term_sb_pop(int cols, VTermScreenCell *cells, void *data)
return 0;
}
- if (term->sb_pending) {
+ if (term->sb_pending > 0) {
term->sb_pending--;
}
@@ -1686,6 +1687,26 @@ static int term_selection_set(VTermSelectionMask mask, VTermStringFragment frag,
return 1;
}
+static int term_sb_clear(void *data)
+{
+ Terminal *term = data;
+
+ if (term->in_altscreen || !term->sb_size || !term->sb_current) {
+ return 1;
+ }
+
+ for (size_t i = 0; i < term->sb_current; i++) {
+ xfree(term->sb_buffer[i]);
+ }
+
+ term->sb_deleted += term->sb_current;
+ term->sb_current = 0;
+ term->sb_pending = 0;
+ invalidate_terminal(term, -1, -1);
+
+ return 1;
+}
+
// }}}
// input handling {{{
@@ -2366,6 +2387,15 @@ static void refresh_scrollback(Terminal *term, buf_T *buf)
int width, height;
vterm_get_size(term->vt, &height, &width);
+ int max_line_count = (int)term->sb_current - term->sb_pending + height;
+ // Remove extra lines at the top if scrollback lines have been deleted.
+ while (deleted > 0 && buf->b_ml.ml_line_count > max_line_count) {
+ ml_delete_buf(buf, 1, false);
+ deleted_lines_buf(buf, 1, 1);
+ deleted--;
+ }
+ max_line_count += term->sb_pending;
+
// May still have pending scrollback after increase in terminal height if the
// scrollback wasn't refreshed in time; append these to the top of the buffer.
int row_offset = term->sb_pending;
@@ -2381,21 +2411,15 @@ static void refresh_scrollback(Terminal *term, buf_T *buf)
// This means that either the window height has decreased or the screen
// became full and libvterm had to push all rows up. Convert the first
// pending scrollback row into a string and append it just above the visible
- // section of the buffer
- if (((int)buf->b_ml.ml_line_count - height) >= (int)term->sb_size) {
- // scrollback full, delete lines at the top
- ml_delete_buf(buf, 1, false);
- deleted_lines_buf(buf, 1, 1);
- }
+ // section of the buffer.
fetch_row(term, -term->sb_pending - row_offset, width);
- int buf_index = (int)buf->b_ml.ml_line_count - height;
+ int buf_index = buf->b_ml.ml_line_count - height;
ml_append_buf(buf, buf_index, term->textbuf, 0, false);
appended_lines_buf(buf, buf_index, 1);
term->sb_pending--;
}
- // Remove extra lines at the bottom
- int max_line_count = (int)term->sb_current + height;
+ // Remove extra lines at the bottom.
while (buf->b_ml.ml_line_count > max_line_count) {
ml_delete_buf(buf, buf->b_ml.ml_line_count, false);
deleted_lines_buf(buf, buf->b_ml.ml_line_count, 1);
diff --git a/test/functional/terminal/altscreen_spec.lua b/test/functional/terminal/altscreen_spec.lua
@@ -52,12 +52,14 @@ describe(':terminal altscreen', function()
line3 |
|*3
]])
+ -- ED 3 is no-op in altscreen
+ feed_data('\027[3J')
+ screen:expect_unchanged()
end)
- describe('on exit', function()
- before_each(exit_altscreen)
-
- it('restores buffer state', function()
+ describe('restores buffer state', function()
+ local function test_exit_altscreen_restores_buffer_state()
+ exit_altscreen()
screen:expect([[
line4 |
line5 |
@@ -77,6 +79,20 @@ describe(':terminal altscreen', function()
line5 |
|
]])
+ end
+
+ it('after exit', function()
+ test_exit_altscreen_restores_buffer_state()
+ end)
+
+ it('after ED 2 and ED 3 and exit', function()
+ feed_data('\027[H\027[2J\027[3J')
+ screen:expect([[
+ ^ |
+ |*5
+ {5:-- TERMINAL --} |
+ ]])
+ test_exit_altscreen_restores_buffer_state()
end)
end)
diff --git a/test/functional/terminal/scrollback_spec.lua b/test/functional/terminal/scrollback_spec.lua
@@ -24,6 +24,7 @@ local function test_terminal_scrollback(hide_curbuf)
local chan --- @type integer
local otherbuf --- @type integer
local restore_terminal_mode --- @type boolean?
+ local save_feed_data = feed_data
local function may_hide_curbuf()
if hide_curbuf then
@@ -56,6 +57,18 @@ local function test_terminal_scrollback(hide_curbuf)
end
end
+ setup(function()
+ feed_data = function(data)
+ may_hide_curbuf()
+ api.nvim_chan_send(chan, data)
+ may_restore_curbuf()
+ end
+ end)
+
+ teardown(function()
+ feed_data = save_feed_data
+ end)
+
--- @param prefix string
--- @param start integer
--- @param stop integer
@@ -72,6 +85,12 @@ local function test_terminal_scrollback(hide_curbuf)
may_restore_curbuf()
end
+ local function try_resize(width, height)
+ may_hide_curbuf()
+ screen:try_resize(width, height)
+ may_restore_curbuf()
+ end
+
before_each(function()
clear()
command('set nostartofline jumpoptions+=view')
@@ -95,6 +114,7 @@ local function test_terminal_scrollback(hide_curbuf)
^ |
{5:-- TERMINAL --} |
]])
+ eq(16, api.nvim_buf_line_count(0))
end)
it('will delete extra lines at the top', function()
@@ -126,7 +146,7 @@ local function test_terminal_scrollback(hide_curbuf)
]])
end)
- it("when outputting fewer than 'scrollback' lines", function()
+ it("outputting fewer than 'scrollback' lines", function()
feed_lines('new_line', 1, 6)
screen:expect([[
line26 |
@@ -141,7 +161,7 @@ local function test_terminal_scrollback(hide_curbuf)
eq({ 0, 7, 6, 0 }, fn.getpos('.'))
end)
- it("when outputting more than 'scrollback' lines", function()
+ it("outputting more than 'scrollback' lines", function()
feed_lines('new_line', 1, 11)
screen:expect([[
line27 |
@@ -156,7 +176,7 @@ local function test_terminal_scrollback(hide_curbuf)
eq({ 0, 2, 6, 0 }, fn.getpos('.'))
end)
- it('when outputting more lines than whole buffer', function()
+ it('outputting more lines than whole buffer', function()
feed_lines('new_line', 1, 20)
screen:expect([[
^new_line6 |
@@ -170,6 +190,53 @@ local function test_terminal_scrollback(hide_curbuf)
eq({ 0, 0, 0, 0 }, fn.getpos("'m"))
eq({ 0, 1, 1, 0 }, fn.getpos('.'))
end)
+
+ it('clearing scrollback with ED 3', function()
+ feed_data('\027[3J')
+ screen:expect_unchanged(hide_curbuf)
+ eq({ 0, 3, 4, 0 }, fn.getpos("'m"))
+ eq({ 0, 3, 6, 0 }, fn.getpos('.'))
+ feed('gg')
+ screen:expect([[
+ line2^6 |
+ line27 |
+ {101:line28} |
+ line29 |
+ line30 |
+ |*2
+ ]])
+ end)
+
+ it('clearing scrollback with ED 3 and outputting lines', function()
+ feed_data('\027[3J' .. 'new_line1\nnew_line2\nnew_line3')
+ screen:expect([[
+ line26 |
+ line27 |
+ {101:line2^8} |
+ line29 |
+ line30 |
+ new_line1 |
+ |
+ ]])
+ eq({ 0, 3, 4, 0 }, fn.getpos("'m"))
+ eq({ 0, 3, 6, 0 }, fn.getpos('.'))
+ end)
+
+ it('clearing scrollback with ED 3 between outputting lines', function()
+ skip(is_os('win'), 'FIXME: wrong behavior on Windows, ConPTY bug?')
+ feed_data('line31\nline32\n' .. '\027[3J' .. 'new_line1\nnew_line2')
+ screen:expect([[
+ {101:line2^8} |
+ line29 |
+ line30 |
+ line31 |
+ line32 |
+ new_line1 |
+ |
+ ]])
+ eq({ 0, 1, 4, 0 }, fn.getpos("'m"))
+ eq({ 0, 1, 6, 0 }, fn.getpos('.'))
+ end)
end)
describe('and cursor on scrollback row #12651', function()
@@ -189,14 +256,14 @@ local function test_terminal_scrollback(hide_curbuf)
]])
end)
- it("when outputting fewer than 'scrollback' lines", function()
+ it("outputting fewer than 'scrollback' lines", function()
feed_lines('new_line', 1, 6)
screen:expect_unchanged(hide_curbuf)
eq({ 0, 4, 4, 0 }, fn.getpos("'m"))
eq({ 0, 4, 6, 0 }, fn.getpos('.'))
end)
- it("when outputting more than 'scrollback' lines", function()
+ it("outputting more than 'scrollback' lines", function()
feed_lines('new_line', 1, 11)
screen:expect([[
^line27 |
@@ -211,6 +278,76 @@ local function test_terminal_scrollback(hide_curbuf)
eq({ 0, 1, 1, 0 }, fn.getpos('.'))
end)
end)
+
+ it('changing window height does not duplicate lines', function()
+ -- XXX: Can't test this reliably on Windows unless the cursor is _moved_
+ -- by the resize. http://docs.libuv.org/en/v1.x/signal.html
+ -- See also: https://github.com/rprichard/winpty/issues/110
+ skip(is_os('win'))
+ try_resize(screen._width, screen._height + 4)
+ screen:expect([[
+ line23 |
+ line24 |
+ line25 |
+ line26 |
+ line27 |
+ line28 |
+ line29 |
+ line30 |
+ rows: 10, cols: 30 |
+ ^ |
+ {5:-- TERMINAL --} |
+ ]])
+ eq(17, api.nvim_buf_line_count(0))
+ try_resize(screen._width, screen._height - 2)
+ screen:expect([[
+ line26 |
+ line27 |
+ line28 |
+ line29 |
+ line30 |
+ rows: 10, cols: 30 |
+ rows: 8, cols: 30 |
+ ^ |
+ {5:-- TERMINAL --} |
+ ]])
+ eq(18, api.nvim_buf_line_count(0))
+ try_resize(screen._width, screen._height - 3)
+ screen:expect([[
+ line30 |
+ rows: 10, cols: 30 |
+ rows: 8, cols: 30 |
+ rows: 5, cols: 30 |
+ ^ |
+ {5:-- TERMINAL --} |
+ ]])
+ eq(15, api.nvim_buf_line_count(0))
+ try_resize(screen._width, screen._height + 3)
+ screen:expect([[
+ line28 |
+ line29 |
+ line30 |
+ rows: 10, cols: 30 |
+ rows: 8, cols: 30 |
+ rows: 5, cols: 30 |
+ rows: 8, cols: 30 |
+ ^ |
+ {5:-- TERMINAL --} |
+ ]])
+ eq(16, api.nvim_buf_line_count(0))
+ feed([[<C-\><C-N>8<C-Y>]])
+ screen:expect([[
+ line20 |
+ line21 |
+ line22 |
+ line23 |
+ line24 |
+ line25 |
+ line26 |
+ ^line27 |
+ |
+ ]])
+ end)
end)
describe('with cursor at last row', function()
@@ -239,7 +376,7 @@ local function test_terminal_scrollback(hide_curbuf)
]])
end)
- it("when outputting more than 'scrollback' lines in Normal mode", function()
+ it("outputting more than 'scrollback' lines in Normal mode", function()
feed([[<C-\><C-N>]])
feed_lines('new_line', 1, 11)
screen:expect([[
@@ -286,6 +423,79 @@ local function test_terminal_scrollback(hide_curbuf)
eq({ 0, 0, 0, 0 }, fn.getpos("'m"))
end)
+ it('clearing scrollback with ED 3', function()
+ -- Clearing empty scrollback and then outputting a line
+ feed_data('\027[3J' .. 'line5\n')
+ screen:expect([[
+ line1 |
+ {101:line2} |
+ line3 |
+ line4 |
+ line5 |
+ ^ |
+ {5:-- TERMINAL --} |
+ ]])
+ eq(7, api.nvim_buf_line_count(0))
+ eq({ 0, 3, 4, 0 }, fn.getpos("'m"))
+ -- Clearing 1 line of scrollback
+ feed_data('\027[3J')
+ screen:expect_unchanged(hide_curbuf)
+ eq(6, api.nvim_buf_line_count(0))
+ eq({ 0, 2, 4, 0 }, fn.getpos("'m"))
+ -- Outputting a line
+ feed_data('line6\n')
+ screen:expect([[
+ {101:line2} |
+ line3 |
+ line4 |
+ line5 |
+ line6 |
+ ^ |
+ {5:-- TERMINAL --} |
+ ]])
+ eq(7, api.nvim_buf_line_count(0))
+ eq({ 0, 2, 4, 0 }, fn.getpos("'m"))
+ -- Clearing 1 line of scrollback and then outputting a line
+ feed_data('\027[3J' .. 'line7\n')
+ screen:expect([[
+ line3 |
+ line4 |
+ line5 |
+ line6 |
+ line7 |
+ ^ |
+ {5:-- TERMINAL --} |
+ ]])
+ eq(7, api.nvim_buf_line_count(0))
+ eq({ 0, 1, 4, 0 }, fn.getpos("'m"))
+ -- Check first line of buffer in Normal mode
+ feed([[<C-\><C-N>gg]])
+ screen:expect([[
+ {101:^line2} |
+ line3 |
+ line4 |
+ line5 |
+ line6 |
+ line7 |
+ |
+ ]])
+ feed('G')
+ -- Outputting lines and then clearing scrollback
+ skip(is_os('win'), 'FIXME: wrong behavior on Windows, ConPTY bug?')
+ feed_data('line8\nline9\n' .. '\027[3J')
+ screen:expect([[
+ line5 |
+ line6 |
+ line7 |
+ line8 |
+ line9 |
+ ^ |
+ |
+ ]])
+ eq(6, api.nvim_buf_line_count(0))
+ eq({ 0, 0, 0, 0 }, fn.getpos("'m"))
+ end)
+
describe('and 1 line is printed', function()
before_each(function()
feed_lines('line', 5, 5)
@@ -361,9 +571,7 @@ local function test_terminal_scrollback(hide_curbuf)
describe('and height decreased by 1', function()
local function will_hide_top_line()
feed([[<C-\><C-N>]])
- may_hide_curbuf()
- screen:try_resize(screen._width - 2, screen._height - 1)
- may_restore_curbuf()
+ try_resize(screen._width - 2, screen._height - 1)
screen:expect([[
{101:line2} |
line3 |
@@ -380,9 +588,7 @@ local function test_terminal_scrollback(hide_curbuf)
describe('and then decreased by 2', function()
before_each(function()
will_hide_top_line()
- may_hide_curbuf()
- screen:try_resize(screen._width - 2, screen._height - 2)
- may_restore_curbuf()
+ try_resize(screen._width - 2, screen._height - 2)
end)
it('will hide the top 3 lines', function()
@@ -423,9 +629,7 @@ local function test_terminal_scrollback(hide_curbuf)
describe('and the height is decreased by 2', function()
before_each(function()
- may_hide_curbuf()
- screen:try_resize(screen._width, screen._height - 2)
- may_restore_curbuf()
+ try_resize(screen._width, screen._height - 2)
end)
local function will_delete_last_two_lines()
@@ -444,9 +648,7 @@ local function test_terminal_scrollback(hide_curbuf)
describe('and then decreased by 1', function()
before_each(function()
will_delete_last_two_lines()
- may_hide_curbuf()
- screen:try_resize(screen._width, screen._height - 1)
- may_restore_curbuf()
+ try_resize(screen._width, screen._height - 1)
end)
it('will delete the last line and hide the first', function()
@@ -500,9 +702,7 @@ local function test_terminal_scrollback(hide_curbuf)
^ |
{5:-- TERMINAL --} |
]])
- may_hide_curbuf()
- screen:try_resize(screen._width, screen._height - 3)
- may_restore_curbuf()
+ try_resize(screen._width, screen._height - 3)
screen:expect([[
line4 |
rows: 3, cols: 30 |
@@ -520,9 +720,7 @@ local function test_terminal_scrollback(hide_curbuf)
return
end
local function pop_then_push()
- may_hide_curbuf()
- screen:try_resize(screen._width, screen._height + 1)
- may_restore_curbuf()
+ try_resize(screen._width, screen._height + 1)
screen:expect([[
line4 |
rows: 3, cols: 30 |
@@ -539,9 +737,7 @@ local function test_terminal_scrollback(hide_curbuf)
before_each(function()
pop_then_push()
eq(8, api.nvim_buf_line_count(0))
- may_hide_curbuf()
- screen:try_resize(screen._width, screen._height + 3)
- may_restore_curbuf()
+ try_resize(screen._width, screen._height + 3)
end)
local function pop3_then_push1()
@@ -576,9 +772,7 @@ local function test_terminal_scrollback(hide_curbuf)
before_each(function()
pop3_then_push1()
feed('Gi')
- may_hide_curbuf()
- screen:try_resize(screen._width, screen._height + 4)
- may_restore_curbuf()
+ try_resize(screen._width, screen._height + 4)
end)
it('will show all lines and leave a blank one at the end', function()
diff --git a/test/functional/terminal/window_spec.lua b/test/functional/terminal/window_spec.lua
@@ -534,8 +534,8 @@ describe(':terminal window', function()
end)
command('botright new')
screen:expect([[
- rows: 2, cols: 25 │rows: 5, cols: 50 |
- │rows: 2, cols: 50 |
+ rows: 2, cols: 25 │rows: 5, cols: 25 |
+ │rows: 5, cols: 50 |
{18:foo [-] foo [-] }|
^ |
{4:~ }|
@@ -545,11 +545,11 @@ describe(':terminal window', function()
command('quit')
eq(1, eval('g:fired'))
screen:expect([[
- rows: 5, cols: 50 │rows: 5, cols: 25 |
- rows: 5, cols: 25 │rows: 5, cols: 50 |
- rows: 2, cols: 25 │rows: 2, cols: 50 |
+ rows: 5, cols: 50 │tty ready |
rows: 5, cols: 25 │rows: 5, cols: 25 |
- ^ │rows: 5, cols: 40 |
+ rows: 2, cols: 25 │rows: 5, cols: 50 |
+ rows: 5, cols: 25 │rows: 2, cols: 50 |
+ ^ │rows: 5, cols: 25 |
{17:foo [-] }{18:foo [-] }|
|
]])
@@ -558,14 +558,28 @@ describe(':terminal window', function()
command('set showtabline=0 | tabnew | tabprevious | wincmd > | tabonly')
eq(2, eval('g:fired'))
screen:expect([[
- rows: 5, cols: 25 │rows: 5, cols: 25 |
- rows: 2, cols: 25 │rows: 5, cols: 50 |
- rows: 5, cols: 25 │rows: 2, cols: 50 |
- rows: 5, cols: 26 │rows: 5, cols: 25 |
- ^ │rows: 5, cols: 40 |
+ rows: 5, cols: 25 │tty ready |
+ rows: 2, cols: 25 │rows: 5, cols: 25 |
+ rows: 5, cols: 25 │rows: 5, cols: 50 |
+ rows: 5, cols: 26 │rows: 2, cols: 50 |
+ ^ │rows: 5, cols: 25 |
{17:foo [-] }{18:foo [-] }|
|
]])
+ n.expect([[
+ tty ready
+ rows: 5, cols: 25
+ rows: 5, cols: 50
+ rows: 2, cols: 50
+ rows: 5, cols: 25
+ rows: 5, cols: 40
+ rows: 5, cols: 25
+ rows: 5, cols: 50
+ rows: 5, cols: 25
+ rows: 2, cols: 25
+ rows: 5, cols: 25
+ rows: 5, cols: 26
+ ]])
end)
it('restores window options when switching terminals', function()