core: cut captured stdout rows where the terminal row continues (#1617)

In split-footer mode with captured stdout, a write that continued a row
(the previous write had no newline) was cut into terminal rows as if it
started at column 0. A wide character that would start on the last
column then sat in a commit that crossed the terminal edge: the terminal
moved it to the next row, but the renderer's row count did not. The next
write overwrote a character, a padding space landed in the middle of a
row, or the wide character was lost. Streamed CJK or emoji output (for
example tokens written as they arrive) hit this whenever a chunk
boundary left a wide character on the last column.

The cut now starts the first row of each write at the predicted tail
column, so no commit crosses the edge and the native row count stays
right without a native change. This also counts tab stops from the start
of the terminal row instead of the start of the write, and a tab now
stops at the last column as in a terminal instead of pushing the next
character to a new row.

Every captured write now reads the predicted tail, so the renderer keeps
the tail after the last queued commit instead of scanning the queue.
Output faster than a frame drains stays linear: 4,000 progress-bar
updates take 6,400 tail steps, not the 6.4 million a scan would.

The split-footer screen tests gain rows that feed the writes through a
VT and compare the screen (wide character at the last column, padding at
the edge, tabs, a wide character that wraps early, unpinned and pinned),
and the captured-stdout commit table gains the matching input-to-rows
vectors. Each row fails on main, and reverting each changed expression
(the row column, the tab clamp, the cache order) fails at least one row.

Fixes #1608
This commit is contained in:
Simon Klee authored and GitHub committed 2026-10-10 07:31:20 +02:00
1 parent 17abb96808
commit 74a273e592
2 files changed
+44 -11

No files matched your search

+23 -8
View File
@@ -463,7 +463,7 @@ class ExternalOutputQueue {
return this.commits.length
}
writeSnapshots(commits: readonly ExternalOutputCommit[]): void {
writeSnapshots(commits: readonly ExternalOutputCommit[]): readonly QueuedCommit[] {
// Every frame renders the queue head, so one invalid entry would fail every later frame and the close flush.
for (const { rowColumns, snapshot } of commits) {
if (!Number.isInteger(rowColumns) || rowColumns < 0 || rowColumns > snapshot.width) {
@@ -480,6 +480,7 @@ class ExternalOutputQueue {
rowWidths: snapshotRowWidths(commit.snapshot, commit.rowColumns),
}))
for (const entry of entries) this.commits.push(entry)
return entries
}
/** The head commits, at most `limit`, that fit one native split render. Never empty unless the queue is. */
@@ -1041,6 +1042,8 @@ export class CliRenderer extends EventEmitter implements RenderContext {
private _splitHeight: number = 0
private renderOffset: number = 0
private splitTailColumn: number = 0
// The tail after the last queued commit, so each captured write reads it in O(1); a reset or a resize voids it.
private queuedSplitTail: { column: number; width: number } | null = null
private pendingSplitFooterTransition: PendingSplitFooterTransition | null = null
// One-shot latch used to request a full split repaint after transitions
// (resize/mode/output-path changes). Cleared after completed presentation.
@@ -2573,9 +2576,12 @@ export class CliRenderer extends EventEmitter implements RenderContext {
private getPendingSplitTailColumn(): number {
const width = Math.max(this.width, 1)
const skipped = this.pendingNativeReplay?.remaining ?? 0
const cached = this.queuedSplitTail
if (this.externalOutputQueue.size > skipped && cached?.width === width) return cached.column
let tailColumn = this.pendingNativeReplay ? 0 : this.splitTailColumn
for (const commit of this.externalOutputQueue.peek().slice(this.pendingNativeReplay?.remaining ?? 0)) {
for (const commit of this.externalOutputQueue.peek().slice(skipped)) {
tailColumn = this.getSplitTailColumnAfterCommit(commit, tailColumn, width)
}
@@ -2609,7 +2615,12 @@ export class CliRenderer extends EventEmitter implements RenderContext {
private enqueueSplitCommits(commits: readonly ExternalOutputCommit[]): void {
for (const commit of commits) commit.nativeSnapshot = commit.snapshot._getSceneHandle(this.nativeScene)
this.externalOutputQueue.writeSnapshots(commits)
const width = Math.max(this.width, 1)
let column = this.getPendingSplitTailColumn()
for (const entry of this.externalOutputQueue.writeSnapshots(commits)) {
column = this.getSplitTailColumnAfterCommit(entry, column, width)
}
this.queuedSplitTail = { column, width }
this.requestRender()
if (this.listenerCount(CliRenderEvents.EXTERNAL_OUTPUT) > 0) {
for (const commit of commits) this.emit(CliRenderEvents.EXTERNAL_OUTPUT, commit)
@@ -2629,6 +2640,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
// Chunk rows at the renderer width in display cells, never inside a grapheme, so
// each commit maps to one terminal row append and keeps every cell.
const width = Math.max(1, this.width)
const tailColumn = this.getPendingSplitTailColumn()
const rows: StdoutRow[] = []
// Wrapped chunks of the current logical row start at this index; '\r' drops them.
let rowStart = 0
@@ -2652,13 +2664,15 @@ export class CliRenderer extends EventEmitter implements RenderContext {
continue
}
// A tab advances to the next stop, as in a terminal, and never wraps.
const tabCells = Math.min(STDOUT_TAB_WIDTH - (cells % STDOUT_TAB_WIDTH), Math.max(0, width - cells))
// The first row of a write continues the terminal row at the tail column; native appends it there.
const column = (rows.length === 0 && tailColumn < width ? tailColumn : 0) + cells
// A tab advances to the next stop, as in a terminal, and stops at the last column.
const tabCells = Math.min(STDOUT_TAB_WIDTH - (column % STDOUT_TAB_WIDTH), Math.max(0, width - 1 - column))
const grapheme = segment === "\t" ? " ".repeat(tabCells) : segment
const graphemeCells = stringWidth(grapheme)
if (cells > 0 && cells + graphemeCells > width) {
// A wide grapheme that does not fit starts a terminal row; pad to the full width so native counts that row.
const padding = Math.max(0, width - cells)
if (column > 0 && column + graphemeCells > width) {
// A grapheme that does not fit starts a terminal row; pad a wide one's skipped cell so native counts the row.
const padding = Math.max(0, width - column)
rows.push({ line: line + " ".repeat(padding), cells: cells + padding, trailingNewline: false })
line = ""
cells = 0
@@ -2795,6 +2809,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
pinnedRenderOffset: this.getSplitPinnedRenderOffset(),
})
this.splitTailColumn = 0
this.queuedSplitTail = null
}
private syncSplitScrollback(): void {
@@ -268,6 +268,16 @@ test.each([
["a captured full-width line of wide characters", ["a一二三四\n"], ["a一二三四"]],
["a captured short line", ["abc\n"], ["abc"]],
["a captured line continued after wide characters", ["你好", "a世界", "x\n"], ["你好a世界", "x"]],
["a continued wide character at the last column", ["abcdefgh", "一x", "y\n"], ["abcdefgh", "一xy"]],
["a wide character at the edge of a continued row", ["abcde", "fghijklm一\n"], ["abcdefghi", "jklm一"]],
["a tab in a continued row", ["abc", "\tX\n"], ["abc X"]],
["a tab that reaches the right margin of a continued row", ["abcdefgh", "\tX\n"], ["abcdefghX"]],
["a continued wide character that wraps early", ["abc", "defgh一", "x\n"], ["abcdefgh", "一x"]],
[
"the same early wrap at the pinned bottom",
["\n".repeat(8), "abc", "defgh一", "x\n"],
["", "", "", "", "abcdefgh", "一x"],
],
[
"rows continued at the pinned bottom",
["\n".repeat(8), "abcde", { red: "XXXXXX\n" }, "abcd", { red: "YY" }, "efg", "z\n", "ab", { red: "一二三四\n" }],
@@ -306,6 +316,12 @@ test.each([
["expands tabs to 8-cell stops within the row", "a\tb\tc\n", ["a b:9", "c:1\n"]],
["ends a line at CRLF", "one\r\ntwo\r\n", ["one:3\n", "two:3\n"]],
["restarts a wrapped line at CR", "0123456789%\r60%\n\n", ["60%:3\n", ":0\n"]],
// Writes that continue a row are cut where the terminal row ends.
["wraps a wide character at the last column of a continued row", ["abcdefgh", "一x"], ["abcdefgh:8", ":1", "一x:3"]],
["pads at the terminal edge, not at the write width", ["abcde", "fghijklm一\n"], ["abcde:5", "fghi:4", "jklm一:6\n"]],
["counts tab stops from the row, not the write", ["a", "bc", "\tX\n"], ["a:1", "bc:2", " X:6\n"]],
["stops a tab at the last column", "\t\tb\n", [" b:9\n"]],
["keeps a wide character that wraps early", ["abc", "defgh一", "x\n"], ["abc:3", "defgh:6", "一:2", "x:1\n"]],
])("captured stdout %s", async (_name, text, commits) => {
const stdout = createTestStdout(9, 10)
const { renderer } = await setup({ width: 9, stdout })
@@ -315,14 +331,16 @@ test.each([
const row = decoder.decode(snapshot.getRealCharBytes(true)).trimEnd()
written.push(`${row}:${rowColumns}${trailingNewline ? "\n" : ""}`)
})
stdout.write(text)
for (const write of [text].flat()) stdout.write(write)
expect(written).toEqual(commits)
})
// After the frame the published tail is 4; the queued newline resets it.
test("the predicted tail column counts every cell of a trailing wide character", async () => {
const stdout = createTestStdout(24, 10)
const { renderer } = await setup({ stdout })
const { renderer, stdout, frame } = await setupTerminal()
stdout.write("ab二")
await frame()
stdout.write("\nab二")
let tailColumn = -1
renderer.writeToScrollback(({ renderContext, tailColumn: tail }) => {
tailColumn = tail