native: keep the last cell of a split scrollback row that fills the width (#1581)

In split-footer mode, native wrote `ESC[K` after each scrollback row to
clear what was left to its right. After a row that ends on the last
column, the cursor stays on that column with a wrap pending, and `ESC[K`
erases from the cursor, so terminals such as Ghostty removed the row's
last character. Captured stdout splits long lines into rows of the full
width, so every line longer than the terminal lost one character at each
wrap point, and a line of exactly the width lost its last one. Writer
snapshots and surfaces that fill the width lost it too.

Native now erases before each row instead: once where the commit starts,
then after each CRLF. That erase also clears the row that a trailing
newline makes current, so the separate erase for it goes. A row that
fits clears the same cells as before.

The old erase after a row also reset the rest of the line that a row
wraps onto. When a row continues mid-line at the pinned bottom and
wraps, the wrap scrolls that line in, and terminals with background
color erase fill it with the background of the cell that wraps, so a
writer row on a background color left that color across the rest of the
line. When the first row of a commit does not fit in the rest of its
line, native now scrolls the next line in first, with default
attributes, and moves back up (`ESC D`, `ESC[A`), so the wrap moves onto
a line that already exists. Nothing is erased after a row, so no cell
can be lost at a pending wrap, and the number of scrolled lines does not
change.

A table in `renderer.native-scene-split.test.ts` feeds the renderer's
terminal bytes into libghostty-vt (`EmbeddedTerminalRenderable`) and
compares the screen: captured lines shorter than, equal to, and longer
than the width, with ASCII and wide characters, a line continued mid-row
over old text on the start row, and writer rows on a red background at
the pinned bottom, whose runs show in brackets. A footer marker on the
screen checks the row count that the table used to read from
`renderOffset`, and the full-width rows fail on main with the last cell
missing. Deleting the first erase, widening it to `ESC[2K`, moving it
back after the row, deleting the pre-scroll, dropping its `ESC[A`, or
changing its test to `>=` or to the snapshot width each makes at least
one row fail.

Other split-footer defects found along the way are not part of this PR;
they will be filed separately.

Fixes #1580
This commit is contained in:
Simon Klee authored and GitHub committed 2026-10-08 18:39:00 +00:00
1 parent 5f6f286c3b
commit af4b9592df
3 files changed
+50 -25

No files matched your search

@@ -5,6 +5,7 @@ import { CliRenderEvents } from "../renderer.js"
import { Renderable, RenderableEvents } from "../Renderable.js"
import { BoxRenderable } from "../renderables/Box.js"
import { CodeRenderable } from "../renderables/Code.js"
import { EmbeddedTerminalRenderable } from "../renderables/EmbeddedTerminal.js"
import { TextRenderable } from "../renderables/Text.js"
import { SyntaxStyle } from "../syntax-style.js"
import {
@@ -256,21 +257,46 @@ test.each(["frames", "suspend", "destroy"] as const)(
)
// A wide grapheme that does not fit the row starts the next one; native must count the rows the terminal uses.
// A row that fills the width keeps its last cell (#1580). The footer starts below the empty row after the newline.
// Old text on the start row (`~`) gives way to the right of a row, and only there. `{ red }` is a 9-cell writer row on a
// red background that continues the last row, and "\n" ends its line; runs on a red background show in brackets.
test.each([
["ASCII", "abcdefghijklmnopqrs", 3],
["wide characters", "一二三四五六七八九", 3],
["a wide character at the last column", "abcdefgh一", 2],
] as const)(
"a captured %s line that wraps advances the split footer by its terminal rows",
async (_name, line, rows) => {
const terminal = await setupTerminal({ columns: 9 })
const renderOffset = () => (terminal.renderer as unknown as { renderOffset: number }).renderOffset
const before = renderOffset()
terminal.stdout.write(line + "\n")
["a captured ASCII line", ["abcdefghijklmnopqrs\n"], ["abcdefghi", "jklmnopqr", "s"]],
["a captured line of wide characters", ["一二三四五六七八九\n"], ["一二三四", "五六七八", "九"]],
["a captured wide character at the last column", ["abcdefgh一\n"], ["abcdefgh", "一"]],
["a captured full-width ASCII line", ["012345678\n"], ["012345678"]],
["a captured full-width line of wide characters", ["a一二三四\n"], ["a一二三四"]],
["a captured short line", ["abc\n"], ["abc"]],
["a captured line continued mid-row", ["ab", "cdefghijk\n"], ["abcdefghi", "jk"]],
[
"rows continued at the pinned bottom",
["1\n2\n3\n4\n5\n6\n7\n8\n", "abcde", { red: "XXXXXX\n" }, "abcd", { red: "YY" }, "efg", "z\n"],
["7", "8", "abcde[XXXX]", "[XX]", "abcd[YY]efg", "z"],
],
] as const)("%s shows its terminal rows above the split footer", async (_name, writes, rows) => {
const terminal = await setupTerminal({ columns: 9 })
terminal.renderer.root.add(new TextRenderable(terminal.renderer, { content: "F" }))
for (const write of writes) {
if (typeof write === "string") terminal.stdout.write(write)
else
terminal.renderer.writeToScrollback(({ renderContext }) => ({
root: new TextRenderable(renderContext, { content: write.red.trimEnd(), width: 9, height: 1, bg: "#ff0000" }),
startOnNewLine: false,
trailingNewline: write.red.endsWith("\n"),
}))
await terminal.frame()
expect(renderOffset() - before).toBe(rows)
},
)
}
const view = await createTestRenderer({ width: 9, height: 10 })
renderers.push(view.renderer)
const vt = new EmbeddedTerminalRenderable(view.renderer, { cols: 9, rows: 10 })
view.renderer.root.add(vt)
vt.write("~".repeat(9) + "\r" + terminal.stdout.text())
await view.renderOnce()
const screen = view
.captureSpans()
.lines.map(({ spans }) => spans.map((s) => (s.bg.r ? `[${s.text}]` : s.text)).join(""))
expect(screen.join("\n").replace(/ +$/gm, "").trimEnd()).toBe([...rows, "", "F"].join("\n"))
})
// Each commit is "text:rowColumns", plus "\n" when it ends its line.
test.each([
+10 -10
View File
@@ -1866,6 +1866,9 @@ pub const CliRenderer = struct {
const hyperlinksEnabled = self.terminal.getCapabilities().hyperlinks;
const render_columns = @min(row_columns, snapshot.width);
// Erase before each row, not after it: a row that fills the width leaves the cursor on
// the last column with a wrap pending, and an erase there removes that cell.
writer.writeAll(ansi.ANSI.reset ++ ansi.ANSI.eraseToEndOfLine) catch {};
for (0..snapshot.height) |uy| {
const y = @as(u32, @intCast(uy));
const row_end = snapshotRowEnd(snapshot, y, render_columns);
@@ -1959,8 +1962,6 @@ pub const CliRenderer = struct {
}
writer.writeAll(ansi.ANSI.reset) catch {};
// Guarantee short rows do not leave stale content from prior frame data.
writer.writeAll(ansi.ANSI.eraseToEndOfLine) catch {};
if (image_state.protocol == .sixel or image_state.protocol == .kitty) {
try self.writeSnapshotNativeImagesForRow(writer, snapshot, y, image_state);
}
@@ -1970,14 +1971,9 @@ pub const CliRenderer = struct {
const is_last_row = @as(u32, @intCast(uy + 1)) >= snapshot.height;
if (!is_last_row or trailing_newline) {
writer.writeAll("\r\n") catch {};
}
if (is_last_row and trailing_newline) {
// After a trailing newline, clear the destination row that becomes
// current so old footer text cannot flash through.
writer.writeAll(ansi.ANSI.reset) catch {};
writer.writeAll(ansi.ANSI.eraseToEndOfLine) catch {};
// The erase also clears the row that a trailing newline makes current, so old
// footer text cannot flash through.
writer.writeAll("\r\n" ++ ansi.ANSI.eraseToEndOfLine) catch {};
}
}
}
@@ -2077,6 +2073,10 @@ pub const CliRenderer = struct {
// Emit CRLF before payload to preserve logical row boundaries
// across commit chunks.
writer.writeAll("\r\n") catch {};
} else if (previousOutputColumn + snapshotRowEnd(snapshot, 0, normalized_row_columns) > self.width) {
// A first row that wraps would scroll its next line in with the background of the
// cell that wraps (BCE). Scroll that line in first, with default attributes.
writer.writeAll(ansi.ANSI.reset ++ "\x1bD\x1b[A") catch {};
}
// Serialize payload rows at current output cursor.
+1 -2
View File
@@ -3591,8 +3591,7 @@ test "renderer - commitSplitFooterSnapshot does not emit NUL padding for short r
const output = test_cli_renderer.lastOutput();
try std.testing.expect(std.mem.findScalar(u8, output, 0) == null);
try std.testing.expect(std.mem.find(u8, output, ansi.ANSI.eraseToEndOfLine) != null);
try std.testing.expect(std.mem.find(u8, output, "\r\n\x1b[0m\x1b[K") != null);
try std.testing.expect(std.mem.find(u8, output, "\x1b[0m\r\n\x1b[K") != null);
}
test "renderer - batched split commits share single sync frame" {