From 0816716c72e88c5d5ccfc89c8d07cde7bb3c4ac0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 15 Jul 2026 09:16:46 +0530 Subject: [PATCH] Add support for DECSTR soft screen reset escape code 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 --- docs/changelog.rst | 2 ++ kitty/screen.c | 30 ++++++++++++++++-------- kitty/screen.h | 1 + kitty/vt-parser.c | 18 +++++++++++---- kitty/window.py | 2 +- kitty_tests/__init__.py | 2 +- kitty_tests/screen.py | 51 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 90 insertions(+), 16 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b18ef82b2..b0a6cb318 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/screen.c b/kitty/screen.c index cf5fff944..ae6e041d3 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -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 diff --git a/kitty/screen.h b/kitty/screen.h index 964fabfc5..cddfcbb67 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -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); diff --git a/kitty/vt-parser.c b/kitty/vt-parser.c index 012b8f6f2..6f7fe49bc 100644 --- a/kitty/vt-parser.c +++ b/kitty/vt-parser.c @@ -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': diff --git a/kitty/window.py b/kitty/window.py index 794d3bb7d..ff47fc309 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -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 diff --git a/kitty_tests/__init__.py b/kitty_tests/__init__.py index 8c2c4a602..9da10a04e 100644 --- a/kitty_tests/__init__.py +++ b/kitty_tests/__init__.py @@ -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() diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py index f3d8f9647..c54344865 100644 --- a/kitty_tests/screen.py +++ b/kitty_tests/screen.py @@ -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)