From 315d78b99befdcc2f9cd1d608384be41cc6c0871 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:46:48 -0700 Subject: [PATCH 1/2] test: a prompt after a padded partial line must stay at column 0 across resize zsh PROMPT_SP pads a partial output line with spaces past the right edge, which soft-wraps into the row where the prompt then starts. Reflow on the next resize joins that row onto the padded row, so the prompt no longer starts at column 0 while zsh redraws it as if it did. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/terminal/Terminal.zig | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index f6c11440874..f3b826cfe0a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -15280,6 +15280,46 @@ test "Terminal: OSC133A cl option sets click to cl value" { try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .cl = .multiple }, t.screens.active.semantic_prompt.click); } +test "Terminal: OSC133A after a padded partial line keeps the prompt on its own line across resize" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // zsh PROMPT_SP: after output with no trailing newline, the shell prints + // a mark and pads with spaces past the right edge so the terminal wraps + // to a new row, then returns to column 0 and starts the prompt there. + try t.printString("ab%"); + try t.printString(" "); + t.carriageReturn(); + try t.printString(" "); + t.carriageReturn(); + try t.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = "redraw=0", + }); + try t.printString("$ "); + try t.semanticPrompt(.init(.end_prompt_start_input)); + try t.printString("ls"); + try testing.expectEqual(@as(size.CellCountInt, 1), t.screens.active.cursor.y); + + // The shell redraws its prompt from column 0 after a resize, so reflow + // must not join the prompt onto the padded row above it. + try t.resize(alloc, .{ .cols = 20, .rows = 5 }); + try testing.expectEqual(@as(size.CellCountInt, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(size.CellCountInt, 4), t.screens.active.cursor.x); + const prompt_start = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; + try testing.expectEqual(@as(u21, '$'), prompt_start.cell.codepoint()); + + try t.resize(alloc, .{ .cols = 6, .rows = 5 }); + const narrowed_start = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = t.screens.active.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '$'), narrowed_start.cell.codepoint()); + try testing.expectEqual(@as(size.CellCountInt, 4), t.screens.active.cursor.x); +} + test "Terminal: OSC133A cl=line sets click to line" { const alloc = testing.allocator; const io_impl = testing.io; From e1b8bf5f478c6aadbf70e51cdbb41930e92fda10 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:51:11 -0700 Subject: [PATCH 2/2] terminal: start an OSC 133;A prompt on its own logical line When a prompt starts at column 0 of a soft-wrapped row, the wrap came from padding such as zsh PROMPT_SP, which prints spaces past the right edge after a partial output line. Reflow joined that row onto the padded row, moving the prompt off column 0, while zsh redraws after SIGWINCH from column 0 of the cursor row. Every resize then left a fragment of the old prompt behind. Reset the soft wrap leading into the row where the prompt starts, so reflow keeps the prompt at the start of a line, where the shell expects it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/terminal/Screen.zig | 19 +++++++++++++++++++ src/terminal/Terminal.zig | 7 +++++++ 2 files changed, 26 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index fac405f51a3..c879bfb59ec 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1614,6 +1614,25 @@ pub fn cursorResetWrap(self: *Screen) void { } } +/// Make the cursor row start a new logical line by resetting the soft wrap +/// from the row above into it. Reflow then never joins the two rows. +pub fn cursorBreakWrapIntoRow(self: *Screen) void { + const page_row = self.cursor.page_row; + if (!page_row.wrap_continuation) return; + page_row.wrap_continuation = false; + + const prev = self.cursor.page_pin.up(1) orelse return; + const prev_row = prev.rowAndCell().row; + prev_row.wrap = false; + + // A spacer head only exists to carry a wide character over the wrap. + const cells = prev.cells(.all); + const last = prev.node.cols() - 1; + if (cells[last].wide == .spacer_head) { + self.clearCells(prev.node.page(), prev_row, cells[last..][0..1]); + } +} + /// Options for scrolling the viewport of the terminal grid. The reason /// we have this in addition to PageList.Scroll is because we have additional /// scroll behaviors that are not part of the PageList.Scroll enum. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index f3b826cfe0a..6e65543728a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1973,6 +1973,13 @@ pub fn semanticPrompt( const screen: *Screen = self.screens.active; + // A prompt that starts at column 0 of a soft-wrapped row reached + // it through padding, such as zsh PROMPT_SP overflowing spaces + // after a partial line. The shell draws and redraws this prompt + // as the start of a line, so reflow must not join it onto the + // padded row above. + if (screen.cursor.x == 0) screen.cursorBreakWrapIntoRow(); + // "Subsequent text (until a OSC "133;B" or OSC "133;I" command) // is a prompt string (as if followed by OSC 133;P;k=i\007)." screen.cursorSetSemanticContent(.{