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.
This commit is contained in:
zeertzjq
2026-02-09 19:28:00 +08:00
committed by GitHub
parent 6ad73421cb
commit e254688016
5 changed files with 308 additions and 60 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()