diff --git a/kitty/screen.c b/kitty/screen.c index cc5673518..25efce52a 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -4600,26 +4600,49 @@ find_cmd_output(Screen *self, OutputOffset *oo, index_type start_screen_y, unsig static PyObject* erase_last_command(Screen *self, PyObject *args) { - int include_prompt = 1; + int include_prompt = 1; // retained for API compatibility; the prompt block is the unit erased if (!PyArg_ParseTuple(args, "|p", &include_prompt)) return NULL; - OutputOffset oo = {.screen=self}; - if (self->linebuf != self->main_linebuf || !find_cmd_output(self, &oo, self->cursor->y + self->scrolled_by, self->scrolled_by, -1, false)) Py_RETURN_FALSE; - if (include_prompt) { - int y = oo.start - 1; Line *line; - while ((line = checked_range_line(self, y))) { - oo.start--; oo.num_lines++; y--; - if (line->attrs.prompt_kind == PROMPT_START) break; - } + (void)include_prompt; + if (self->linebuf != self->main_linebuf) Py_RETURN_FALSE; + Line *line; + // Erase the most recent command: the prompt block immediately above the + // current (live) prompt, regardless of whether that command produced any + // output. Commands without output (an empty Enter, a comment, cd, export, + // ...) emit no OSC 133;C, so a search anchored on OUTPUT_START would skip + // them and erase an older command instead. Selecting by prompt marks makes + // every submitted command one unit, removed newest-first. + int y = self->cursor->y + self->scrolled_by; + bool found = false; + for (; (line = checked_range_line(self, y)); y--) { + if (line->attrs.prompt_kind == PROMPT_START) { found = true; break; } } - index_type num_lines_to_erase_in_screen = oo.start >= 0 ? oo.num_lines : oo.num_lines + oo.start; + if (!found) Py_RETURN_FALSE; + const int live_prompt_start = y; + found = false; + for (y = live_prompt_start - 1; (line = checked_range_line(self, y)); y--) { + if (line->attrs.prompt_kind == PROMPT_START) { found = true; break; } + } + if (!found) Py_RETURN_FALSE; // nothing above the current prompt to erase + const int start = y; + const index_type num_lines = (index_type)(live_prompt_start - start); + index_type num_lines_to_erase_in_screen = start >= 0 ? num_lines : num_lines + start; num_lines_to_erase_in_screen = MIN(self->cursor->y, num_lines_to_erase_in_screen); if (num_lines_to_erase_in_screen) { - screen_delete_lines_impl(self, self->cursor->y - num_lines_to_erase_in_screen, num_lines_to_erase_in_screen, 0, self->lines - 1); + // Anchor the on-screen deletion at the top of the region, not at + // cursor->y - count: a multi-line prompt or rows skipped above would + // otherwise shift the deletion and leave residual lines on screen. + index_type screen_erase_start = start >= 0 ? (index_type)start : 0; + screen_delete_lines_impl(self, screen_erase_start, num_lines_to_erase_in_screen, 0, self->lines - 1); self->cursor->y -= num_lines_to_erase_in_screen; } - if (oo.num_lines > num_lines_to_erase_in_screen) { - index_type num_of_lines_to_erase_from_history = oo.num_lines - num_lines_to_erase_in_screen; - historybuf_delete_newest_lines(self->historybuf, num_of_lines_to_erase_from_history); + if (num_lines > num_lines_to_erase_in_screen) { + historybuf_delete_newest_lines(self->historybuf, num_lines - num_lines_to_erase_in_screen); + // The scrollback shrank: clamp the scroll position and signal a redraw, + // otherwise the deletion of off-screen lines is not reflected until the + // next scroll event forces the history viewport to be recomputed. + self->scrolled_by = MIN(self->scrolled_by, self->historybuf->count); + self->is_dirty = true; + dirty_scroll(self); } Py_RETURN_TRUE; } diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py index db4c403d5..4d8fbc339 100644 --- a/kitty_tests/screen.py +++ b/kitty_tests/screen.py @@ -1627,8 +1627,8 @@ class TestScreen(BaseTest): s.draw('before\r\n') draw_prompt('p1'), draw_output(2), mark_prompt(), s.draw('partial') x = s.cursor.x - s.erase_last_command(False) - self.ae('before\n$ p1\npartial', at().rstrip()) + s.erase_last_command(False) # include_prompt is now a no-op: the prompt block is the unit erased + self.ae('before\npartial', at().rstrip()) for scroll in (8, 9, 10): s.reset() s.draw('before'), s.carriage_return(), s.linefeed() @@ -1641,6 +1641,30 @@ class TestScreen(BaseTest): s.erase_last_command() self.ae(at().rstrip(), ' a b\npartial') + # the most recent command is erased even when it produced no output (an + # empty Enter, a comment, cd, ...): such commands emit no OSC 133;C and + # must not be skipped in favour of an older command-with-output. + s = self.create_screen(cols=10, lines=12, scrollback=30) + s.draw('before\r\n') + draw_prompt('p1'), draw_output(2), draw_prompt('# note'), mark_prompt(), s.draw('partial') + s.erase_last_command() + self.ae('before\n$ p1\n0\n1\npartial', at().rstrip()) # the output-less command goes first + s.erase_last_command() + self.ae('before\npartial', at().rstrip()) # then the command with output + # consecutive output-less commands are removed newest-first, one per call + s.reset() + s.draw('before\r\n') + draw_prompt('p1'), draw_output(1), draw_prompt('e1'), draw_prompt('e2'), mark_prompt(), s.draw('partial') + s.erase_last_command(); self.ae('before\n$ p1\n0\n$ e1\npartial', at().rstrip()) + s.erase_last_command(); self.ae('before\n$ p1\n0\npartial', at().rstrip()) + s.erase_last_command(); self.ae('before\npartial', at().rstrip()) + # multi-line live prompt: the command region is erased with no residual + s.reset() + s.draw('before\r\n') + draw_prompt('p1'), draw_output(9), mark_prompt(), s.draw('l1'), s.carriage_return(), s.index(), s.draw('partial') + s.erase_last_command() + self.ae('before\nl1\npartial', at().rstrip()) + def test_pointer_shapes(self): from kitty.window import set_pointer_shape s = self.create_screen()