From 918c2934a36d275dde002e4e1bf757e46f3fa927 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 15:09:43 -0800 Subject: [PATCH] terminal: add redraw=last for bash for OSC133 --- src/Surface.zig | 2 +- src/shell-integration/bash/ghostty.bash | 4 +- src/terminal/Screen.zig | 153 +++++++++++++++---- src/terminal/Terminal.zig | 2 +- src/terminal/osc/parsers/semantic_prompt.zig | 50 ++++-- src/terminal/stream_readonly.zig | 2 +- 6 files changed, 170 insertions(+), 43 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index fa9b04685..44385fdae 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4295,7 +4295,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { // This flag is only set if we've seen at least one semantic prompt // OSC sequence. If we've never seen that sequence, we can't possibly // move the cursor so we can fast path out of here. - if (!t.flags.shell_redraws_prompt) return; + if (!t.screens.active.flags.semantic_content) return; // Get our path const from = t.screens.active.cursor.page_pin.*; diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index a5417f1b6..40fd71b19 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -201,7 +201,7 @@ function __ghostty_precmd() { # Marks. We need to do fresh line (A) at the beginning of the prompt # since if the cursor is not at the beginning of a line, the terminal # will emit a newline. - PS1='\[\e]133;A\a\]'$PS1'\[\e]133;B\a\]' + PS1='\[\e]133;A;redraw=last\a\]'$PS1'\[\e]133;B\a\]' PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]' # Bash doesn't redraw the leading lines in a multiline prompt so @@ -240,7 +240,7 @@ function __ghostty_precmd() { fi # Fresh line and start of prompt. - builtin printf "\e]133;A;aid=%s\a" "$BASHPID" + builtin printf "\e]133;A;redraw=last;aid=%s\a" "$BASHPID" _ghostty_executing=0 } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 05b84d25f..bf35b75df 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1608,11 +1608,11 @@ pub const Resize = struct { /// lost from the top of the scrollback. reflow: bool = true, - /// Set this to true to enable prompt redraw on resize. This signals + /// Set this to enable prompt redraw on resize. This signals /// that the running program can redraw the prompt if the cursor is /// currently at a prompt. This detects OSC133 prompts lines and clears - /// them. - prompt_redraw: bool = false, + /// them. If set to `.last`, only the most recent prompt line is cleared. + prompt_redraw: osc.semantic_prompt.Redraw = .false, }; /// Resize the screen. The rows or cols can be bigger or smaller. @@ -1682,32 +1682,47 @@ pub inline fn resize( // If our cursor is on a prompt line, then we clear the prompt so // the shell can redraw it. This works with OSC133 semantic prompts. - if (opts.prompt_redraw and + if (opts.prompt_redraw != .false and self.cursor.page_row.semantic_prompt != .none) prompt: { - const start = start: { - var it = self.cursor.page_pin.promptIterator( - .left_up, - null, - ); - break :start it.next() orelse { - // This should never happen because promptIterator should always - // find a prompt if we already verified our row is some kind of - // prompt. - log.warn("cursor on prompt line but promptIterator found no prompt", .{}); - break :prompt; - }; - }; + switch (opts.prompt_redraw) { + .false => unreachable, - // Clear cells from our start down. We replace it with spaces, - // and do not physically erase the rows (eraseRows) because the - // shell is going to expect this space to be available. - var it = start.rowIterator(.right_down, null); - while (it.next()) |pin| { - const page = &pin.node.data; - const row = pin.rowAndCell().row; - const cells = page.getCells(row); - self.clearCells(page, row, cells); + // For `.last`, only clear the current line where the cursor is. + // For `.true`, clear all prompt lines starting from the beginning. + .last => { + const page = &self.cursor.page_pin.node.data; + const row = self.cursor.page_row; + const cells = page.getCells(row); + self.clearCells(page, row, cells); + }, + + .true => { + const start = start: { + var it = self.cursor.page_pin.promptIterator( + .left_up, + null, + ); + break :start it.next() orelse { + // This should never happen because promptIterator should always + // find a prompt if we already verified our row is some kind of + // prompt. + log.warn("cursor on prompt line but promptIterator found no prompt", .{}); + break :prompt; + }; + }; + + // Clear cells from our start down. We replace it with spaces, + // and do not physically erase the rows (eraseRows) because the + // shell is going to expect this space to be available. + var it = start.rowIterator(.right_down, null); + while (it.next()) |pin| { + const page = &pin.node.data; + const row = pin.rowAndCell().row; + const cells = page.getCells(row); + self.clearCells(page, row, cells); + } + }, } } @@ -7191,7 +7206,7 @@ test "Screen: resize more cols with cursor at prompt" { try s.resize(.{ .cols = 20, .rows = 3, - .prompt_redraw = true, + .prompt_redraw = .true, }); // Cursor should not move @@ -7232,7 +7247,7 @@ test "Screen: resize more cols with cursor not at prompt" { try s.resize(.{ .cols = 20, .rows = 3, - .prompt_redraw = true, + .prompt_redraw = .true, }); // Cursor should not move @@ -7247,6 +7262,88 @@ test "Screen: resize more cols with cursor not at prompt" { } } +test "Screen: resize with prompt_redraw last clears only one line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 5 }); + defer s.deinit(); + + // zig fmt: off + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_eol }); + try s.testWriteString("hello\n"); + try s.testWriteString("world"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> hello\nworld"; + try testing.expectEqualStrings(expected, contents); + } + + // Move cursor back to the prompt line (row 1) + s.cursorAbsolute(7, 1); + + try s.resize(.{ + .cols = 20, + .rows = 4, + .prompt_redraw = .last, + }); + + // With .last, only the first prompt line ("> ") should be cleared, + // but subsequent input lines ("hello", "world") remain + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n\nworld"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize with prompt_redraw last multiline prompt clears only last line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 5 }); + defer s.deinit(); + + // Create a 3-line prompt: 1 initial + 2 continuation lines + // zig fmt: off + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("line1\n"); + s.cursorSetSemanticContent(.{ .prompt = .continuation }); + try s.testWriteString("line2\n"); + s.cursorSetSemanticContent(.{ .prompt = .continuation }); + try s.testWriteString("line3"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "line1\nline2\nline3"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor is at end of line3 (the last continuation line) + try s.resize(.{ + .cols = 30, + .rows = 5, + .prompt_redraw = .last, + }); + + // With .last, only line3 (where cursor is) should be cleared + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "line1\nline2"; + try testing.expectEqualStrings(expected, contents); + } +} + test "Screen: select untracked" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 4635b2a58..10e6f1630 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -83,7 +83,7 @@ flags: packed struct { // This supports a Kitty extension where programs using semantic // prompts (OSC133) can annotate their new prompts with `redraw=0` to // disable clearing the prompt on resize. - shell_redraws_prompt: bool = true, + shell_redraws_prompt: osc.semantic_prompt.Redraw = .true, // This is set via ESC[4;2m. Any other modify key mode just sets // this to false and we act in mode 1 by default. diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index 61aed4988..9014312f4 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -49,10 +49,8 @@ pub const Option = enum { err, // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - // Kitty supports a "redraw" option for prompt_start. I can't find - // this documented anywhere but can see in the code that this is used - // by shell environments to tell the terminal that the shell will NOT - // redraw the prompt so we should attempt to resize it. + // Kitty supports a "redraw" option for prompt_start. This is extended + // by Ghostty with the "last" option. See Redraw the type for more details. redraw, // Use a special key instead of arrow keys to move the cursor on @@ -82,7 +80,7 @@ pub const Option = enum { .cl => Click, .prompt_kind => PromptKind, .err => []const u8, - .redraw => bool, + .redraw => Redraw, .special_key => bool, .click_events => bool, .exit_code => i32, @@ -170,7 +168,15 @@ pub const Option = enum { .cl => std.meta.stringToEnum(Click, value), .prompt_kind => if (value.len == 1) PromptKind.init(value[0]) else null, .err => value, - .redraw, .special_key, .click_events => if (value.len == 1) switch (value[0]) { + .redraw => if (std.mem.eql(u8, value, "0")) + .false + else if (std.mem.eql(u8, value, "1")) + .true + else if (std.mem.eql(u8, value, "last")) + .last + else + null, + .special_key, .click_events => if (value.len == 1) switch (value[0]) { '0' => false, '1' => true, else => null, @@ -209,6 +215,29 @@ pub const PromptKind = enum { } }; +/// The values for the `redraw` extension to OSC133. This was +/// started by Kitty[1] and extended by Ghostty (the "last" option). +/// +/// [1]: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers +pub const Redraw = enum(u2) { + /// The shell supports redrawing the full prompt and all continuations. + /// This is the default value, it does not need to be explicitly set + /// unless it is to reset a prior other value. + true, + + /// The shell does NOT support redrawing. In this case, Ghostty will NOT + /// clear any prompt lines on resize. + false, + + /// The shell supports redrawing only the LAST line of the prompt. + /// Ghostty will only clear the last line of the prompt on resize. + /// + /// This is specifically introduced because Bash only redraws the last + /// line. It is literally the only shell that does this and it does this + /// because its bad and they should feel bad. Don't be like Bash. + last, +}; + /// Parse OSC 133, semantic prompts pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { const writer = parser.writer orelse { @@ -514,7 +543,7 @@ test "OSC 133: fresh_line_new_prompt with redraw=0" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == false); + try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == .false); } test "OSC 133: fresh_line_new_prompt with redraw=1" { @@ -528,7 +557,7 @@ test "OSC 133: fresh_line_new_prompt with redraw=1" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == true); + try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == .true); } test "OSC 133: fresh_line_new_prompt with invalid redraw" { @@ -871,8 +900,9 @@ test "Option.read err" { test "Option.read redraw" { const testing = std.testing; - try testing.expect(Option.redraw.read("redraw=1").? == true); - try testing.expect(Option.redraw.read("redraw=0").? == false); + try testing.expect(Option.redraw.read("redraw=1").? == .true); + try testing.expect(Option.redraw.read("redraw=0").? == .false); + try testing.expect(Option.redraw.read("redraw=last").? == .last); try testing.expect(Option.redraw.read("redraw=2") == null); try testing.expect(Option.redraw.read("redraw=10") == null); try testing.expect(Option.redraw.read("redraw=") == null); diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 91532c9d5..eca13bf06 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -904,7 +904,7 @@ test "semantic prompt fresh line new prompt" { // Test with redraw option try s.nextSlice("prompt$ "); try s.nextSlice("\x1b]133;A;redraw=1\x07"); - try testing.expect(t.flags.shell_redraws_prompt); + try testing.expect(t.flags.shell_redraws_prompt == .true); } test "semantic prompt end of input, then start output" {