terminal: add redraw=last for bash for OSC133

This commit is contained in:
Mitchell Hashimoto
2026-01-31 15:09:43 -08:00
parent 4bee8202a8
commit 918c2934a3
6 changed files with 170 additions and 43 deletions

View File

@@ -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.*;

View File

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

View File

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

View File

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

View File

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

View File

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