Add support for DECSTR soft screen reset escape code
Some checks are pending
CI / Linux (python=3.14 cc=clang sanitize=1) (push) Waiting to run
CI / Linux (python=3.12 cc=gcc sanitize=0) (push) Waiting to run
CI / Linux (python=3.13 cc=gcc sanitize=1) (push) Waiting to run
CI / Linux package (push) Waiting to run
CI / Bundle test (macos-latest) (push) Waiting to run
CI / Bundle test (ubuntu-latest) (push) Waiting to run
CI / macOS Brew (push) Waiting to run
CI / Test ./dev.sh and benchmark (push) Waiting to run
CodeQL / CodeQL-Build (actions, ubuntu-latest) (push) Waiting to run
CodeQL / CodeQL-Build (c, macos-latest) (push) Waiting to run
CodeQL / CodeQL-Build (c, ubuntu-latest) (push) Waiting to run
CodeQL / CodeQL-Build (go, ubuntu-latest) (push) Waiting to run
CodeQL / CodeQL-Build (python, ubuntu-latest) (push) Waiting to run
Depscan / Scan dependencies for vulnerabilities (push) Waiting to run

This escape code is largely undefined. There is no specification for how
it affects alternate screen mode, overriden colors, kitty keyboard
state, paused rendering, etc. Do what I feel is sensible in these cases.

Fixes #10263
This commit is contained in:
Kovid Goyal 2026-07-15 09:16:46 +05:30
parent 41c82aa418
commit 0816716c72
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
7 changed files with 90 additions and 16 deletions

View file

@ -234,6 +234,8 @@ Detailed list of changes
- macOS: Disable macOS one time code autofill popups (:pull:`10250`)
- Add support for DECSTR soft screen reset escape code (:iss:`10263`)
0.47.4 [2026-06-15]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -173,13 +173,13 @@ new_screen_object(PyTypeObject *type, PyObject *args, PyObject UNUSED *kwds) {
static Line* range_line_(Screen *self, int y);
void
screen_reset(Screen *self) {
static void
do_screen_reset(Screen *self, bool is_hard_reset) {
screen_pause_rendering(self, false, 0);
self->dnd_chunking.active = false;
self->extra_cursors.count = 0; zero_at_ptr(&self->extra_cursors.color); self->extra_cursors.dirty = true;
self->main_pointer_shape_stack.count = 0; self->alternate_pointer_shape_stack.count = 0;
if (self->linebuf == self->alt_linebuf) screen_toggle_screen_buffer(self, true, true);
if (is_hard_reset && self->linebuf == self->alt_linebuf) screen_toggle_screen_buffer(self, true, true);
if (screen_is_overlay_active(self)) {
deactivate_overlay_line(self);
// Cancel IME composition
@ -197,11 +197,13 @@ screen_reset(Screen *self) {
self->last_graphic_char = 0;
self->main_savepoint.is_valid = false;
self->alt_savepoint.is_valid = false;
linebuf_clear(self->linebuf, BLANK_CHAR);
historybuf_clear(self->historybuf);
clear_hyperlink_pool(self->hyperlink_pool);
grman_clear(self->main_grman, false, self->cell_size); // dont delete images in scrollback
grman_clear(self->alt_grman, true, self->cell_size);
if (is_hard_reset) {
linebuf_clear(self->linebuf, BLANK_CHAR);
historybuf_clear(self->historybuf);
clear_hyperlink_pool(self->hyperlink_pool);
grman_clear(self->main_grman, false, self->cell_size); // dont delete images in scrollback
grman_clear(self->alt_grman, true, self->cell_size);
}
self->modes = empty_modes;
self->saved_modes = empty_modes;
self->active_hyperlink_id = 0;
@ -218,7 +220,17 @@ screen_reset(Screen *self) {
screen_cursor_position(self, 1, 1);
set_dynamic_color(self, 111, NULL); // does default_bg_changed processing
colorprofile_reset(self->color_profile);
CALLBACK("on_reset", NULL)
CALLBACK("on_reset", "O", is_hard_reset ? Py_True : Py_False);
}
void
screen_reset(Screen *self) { do_screen_reset(self, true); }
void
screen_soft_reset(Screen *self) {
index_type x = self->cursor->x, y = self->cursor->y;
do_screen_reset(self, false);
self->cursor->x = x; self->cursor->y = y;
}
void

View file

@ -242,6 +242,7 @@ void screen_scroll(Screen *self, unsigned int count);
void screen_reverse_scroll(Screen *self, unsigned int count);
void screen_reverse_scroll_and_fill_from_scrollback(Screen *self, unsigned int count);
void screen_reset(Screen *self);
void screen_soft_reset(Screen *self);
void screen_set_tab_stop(Screen *self);
void screen_tab(Screen *self);
void screen_backtab(Screen *self, unsigned int);

View file

@ -1380,11 +1380,19 @@ dispatch_csi(PS *self) {
}
break;
case DECSTR:
if (end_modifier == '$') {
// DECRQM
CALL_CSI_HANDLER1P(report_mode_status, 0, '?');
} else {
REPORT_ERROR("Unknown DECSTR CSI sequence with start and end modifiers: '%c' '%c'", start_modifier, end_modifier);
switch (end_modifier) {
case '$': // DECRQM
CALL_CSI_HANDLER1P(report_mode_status, 0, '?'); break;
case '!': // Soft reset
if (num_params) {
REPORT_ERROR("DECSTR escape code with parameters is invalid");
} else {
REPORT_COMMAND(screen_soft_reset);
screen_soft_reset(self->screen);
}
break;
default:
REPORT_ERROR("Unknown CSI p sequence with start and end modifiers: '%c' '%c'", start_modifier, end_modifier); break;
}
break;
case 'm':

View file

@ -1499,7 +1499,7 @@ class Window:
b |= b << 8
self.screen.send_escape_code_to_child(ESC_OSC, f'{code};rgb:{r:04x}/{g:04x}/{b:04x}')
def on_reset(self) -> None:
def on_reset(self, is_hard_reset: bool = True) -> None:
from .progress import ProgressState
if self.progress.state is not ProgressState.unset:
self.progress.update(0) # unset

View file

@ -60,7 +60,7 @@ class Callbacks:
def notify_child_of_resize(self):
self.num_of_resize_events += 1
def on_reset(self) -> None:
def on_reset(self, is_hard_reset: bool = True) -> None:
if self.pty is not None:
self.pty.reset_termios_state()

View file

@ -1864,6 +1864,57 @@ class TestScreen(BaseTest):
sc(2, 1, 2, 3, slot=slot)
sc(5, 13, slot=slot)
def test_soft_reset(self):
SOFT_RESET = b'\x1b[!p' # DECSTR sequence
# Screen content is preserved (unlike hard reset)
s = self.create_screen()
s.draw('hello')
parse_bytes(s, SOFT_RESET)
self.ae(str(s.line(0)), 'hello')
# Hard reset clears screen content; soft reset does not
s = self.create_screen()
s.draw('hello')
s.reset()
self.ae(str(s.line(0)), '')
# Cursor SGR attributes are cleared
s = self.create_screen()
s.select_graphic_rendition(1) # bold
s.select_graphic_rendition(31) # red fg
self.assertTrue(s.cursor.bold)
self.assertNotEqual(s.cursor.fg, 0)
parse_bytes(s, SOFT_RESET)
self.assertFalse(s.cursor.bold)
self.ae(s.cursor.fg, 0)
# Cursor position is preserved
s = self.create_screen()
s.cursor_position(3, 4)
self.ae((s.cursor.y, s.cursor.x), (2, 3))
parse_bytes(s, SOFT_RESET)
self.ae((s.cursor.y, s.cursor.x), (2, 3))
# Insert mode (IRM) is cleared
s = self.create_screen()
s.draw('abcde')
s.set_mode(IRM)
parse_bytes(s, SOFT_RESET)
# without IRM, drawing overwrites
s.cursor.x = 0
s.draw('X')
self.ae(str(s.line(0)), 'Xbcde')
# Alternate screen is NOT exited on soft reset
s = self.create_screen()
parse_bytes(s, b'\x1b[?1049h') # enter alternate screen
self.assertFalse(s.is_main_linebuf())
parse_bytes(s, SOFT_RESET)
self.assertFalse(s.is_main_linebuf())
s.reset()
self.assertTrue(s.is_main_linebuf())
def detect_url(self, scale=1):
s = self.create_screen(cols=30 * scale)