Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/tui/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# TUI delta rendering fork changes

## 2026-08-14: main-screen shrink keeps the document tail at the buffer bottom

### What changed

- Stable-size, non-multiplexer main-screen renders now retain a tracked blank gap at the first changed document row
when a mounted tail shrinks. The physical frame stays the same length, so the editor/status/footer remain on the
terminal's bottom row without clearing or replaying scrollback.
- Later document growth consumes the gap before it can extend the terminal buffer. Cursor coordinates, memoized raw
lines, Kitty image row boundaries, lifecycle resets, and main-screen/fullscreen state transfers account for the gap.
- Default scrollback replay no longer emits `ESC[3J`; the explicit legacy-mux renderer keeps its previous byte shape.
- Headless-xterm regressions cover a selector-shaped grow/shrink/regrow cycle, bounded repaint work, cursor placement,
zero trailing blank rows, no screen/scrollback clears, and no duplicated history.

### Why

Inline selectors and autocomplete temporarily add rows above the editor tail. Removing those rows previously moved the
rendered tail upward in place while the terminal buffer bottom stayed fixed, leaving a scrollable blank region below
Senpi. Clearing the screen fixed the geometry only by destroying scrollback, while replaying the document duplicated
history and made repaint cost scale with the transcript.

### Why this cannot be expressed externally

The fix depends on private renderer snapshots, viewport offsets, normalization memo state, hardware-cursor rows, and
terminal diff planning. Components can change their rendered line count but cannot preserve the physical line-space
mapping or safely alter the emitted cursor operations.

### Expected merge conflict zones

- HIGH: `packages/tui/src/tui.ts` around main-screen render state, normalization input, scrollback replay, and
`doRender()` cursor extraction.
- LOW: `packages/tui/src/tui-main-screen.ts` render-state transfer fields and shrink/render regression assertions.

## 2026-08-05: dead-terminal raw-mode restoration is best-effort during shutdown

### What changed
Expand Down
9 changes: 9 additions & 0 deletions packages/tui/src/tui-main-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { TuiBase } from "./tui.ts";

export interface TuiMainScreenRenderState {
previousLines: string[];
previousRawLines: string[];
previousWidth: number;
previousHeight: number;
cursorRow: number;
hardwareCursorRow: number;
maxLinesRendered: number;
previousViewportTop: number;
tailAnchorGap: number;
tailAnchorGapIndex: number;
}

/** TUI implementation that renders into the terminal's main screen and scrollback. */
Expand All @@ -18,23 +21,29 @@ export class TuiMainScreen extends TuiBase {
captureRenderState(): TuiMainScreenRenderState {
return {
previousLines: [...this.previousLines],
previousRawLines: [...this.previousRawLines],
previousWidth: this.previousWidth,
previousHeight: this.previousHeight,
cursorRow: this.cursorRow,
hardwareCursorRow: this.hardwareCursorRow,
maxLinesRendered: this.maxLinesRendered,
previousViewportTop: this.previousViewportTop,
tailAnchorGap: this.tailAnchorGap,
tailAnchorGapIndex: this.tailAnchorGapIndex,
};
}

restoreRenderState(state: TuiMainScreenRenderState): void {
this.previousLines = state.previousLines.map((line) => (isImageLine(line) ? "" : line));
this.previousRawLines = state.previousRawLines.map((line) => (isImageLine(line) ? "" : line));
this.previousKittyImageIds = new Set();
this.previousWidth = state.previousWidth;
this.previousHeight = state.previousHeight;
this.cursorRow = state.cursorRow;
this.hardwareCursorRow = state.hardwareCursorRow;
this.maxLinesRendered = state.maxLinesRendered;
this.previousViewportTop = state.previousViewportTop;
this.tailAnchorGap = state.tailAnchorGap;
this.tailAnchorGapIndex = state.tailAnchorGapIndex;
}
}
124 changes: 118 additions & 6 deletions packages/tui/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ export abstract class TuiBase extends Container {
abstract readonly mode: TuiMode;
public terminal: Terminal;
protected previousLines: string[] = [];
private previousRawLines: string[] = [];
protected previousRawLines: string[] = [];
private normalizeMemo = new Map<string, string>();
protected previousKittyImageIds = new Set<number>();
protected previousWidth = 0;
Expand All @@ -579,6 +579,9 @@ export abstract class TuiBase extends Container {
private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
protected maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
protected previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
// Blank rows retained above a shrunken tail so the terminal buffer bottom does not move away from the UI.
protected tailAnchorGap = 0;
protected tailAnchorGapIndex = 0;
protected fullRedrawCount = 0;
private muxViewportRepaintCount = 0;
private overWideCrashDumpWritten = false;
Expand Down Expand Up @@ -1031,6 +1034,8 @@ export abstract class TuiBase extends Container {
this.hardwareCursorRow = 0;
this.maxLinesRendered = 0;
this.previousViewportTop = 0;
this.tailAnchorGap = 0;
this.tailAnchorGapIndex = 0;
}

renderNow(force = false): void {
Expand Down Expand Up @@ -1102,6 +1107,8 @@ export abstract class TuiBase extends Container {
this.hardwareCursorRow = 0;
this.maxLinesRendered = 0;
this.previousViewportTop = 0;
this.tailAnchorGap = 0;
this.tailAnchorGapIndex = 0;
}

private cancelRenderTimer(): void {
Expand Down Expand Up @@ -1506,6 +1513,104 @@ export abstract class TuiBase extends Container {
this.previousRawLines = rawLines;
}

private resetTailAnchorGap(): void {
this.tailAnchorGap = 0;
this.tailAnchorGapIndex = 0;
}

private getPreviousDocumentLength(): number {
return Math.max(0, this.previousRawLines.length - this.tailAnchorGap);
}

private getPreviousDocumentLine(index: number): string {
const rawIndex = index < this.tailAnchorGapIndex ? index : index + this.tailAnchorGap;
return this.previousRawLines[rawIndex] ?? "";
}

private findFirstDocumentChange(lines: string[]): number {
const previousLength = this.getPreviousDocumentLength();
const sharedLength = Math.min(previousLength, lines.length);
for (let index = 0; index < sharedLength; index++) {
if (this.getPreviousDocumentLine(index) !== lines[index]) {
return index;
}
}
return sharedLength;
}

private clampTailAnchorGapIndex(lines: string[], requestedIndex: number): number {
let gapIndex = Math.max(0, Math.min(requestedIndex, lines.length - 1));
for (let index = 0; index < gapIndex; index++) {
if (!isImageLine(lines[index] ?? "")) continue;
const imageEnd = index + this.getKittyImageReservedRows(lines, index);
if (gapIndex < imageEnd) {
gapIndex = index;
break;
}
}
return gapIndex;
}

/** Keep shrink/regrowth frames the same physical length while a bottom-anchored tail remains mounted. */
private applyTailAnchorGap(
lines: string[],
height: number,
stableDimensions: boolean,
muxSession: boolean,
): string[] {
if (
!stableDimensions ||
muxSession ||
this.clearOnShrink ||
this.previousRawLines.length === 0 ||
lines.length === 0
) {
this.resetTailAnchorGap();
return lines;
}

const previousDocumentLength = this.getPreviousDocumentLength();
const lineCountDelta = lines.length - previousDocumentLength;
const firstChanged = lineCountDelta === 0 ? -1 : this.findFirstDocumentChange(lines);
const previousTail = this.previousRawLines[this.previousRawLines.length - 1] ?? "";
const tailWasAnchored =
this.previousLines.length === this.previousViewportTop + height &&
(isImageLine(previousTail) || visibleWidth(previousTail) > 0);

let nextGap = this.tailAnchorGap;
let nextGapIndex = this.tailAnchorGapIndex;
if (lineCountDelta < 0) {
if (nextGap > 0) {
nextGap -= lineCountDelta;
if (firstChanged < nextGapIndex) {
nextGapIndex += lineCountDelta;
}
} else if (tailWasAnchored && firstChanged < lines.length) {
nextGap = -lineCountDelta;
nextGapIndex = firstChanged;
}
} else if (lineCountDelta > 0 && nextGap > 0) {
nextGap = Math.max(0, nextGap - lineCountDelta);
if (firstChanged < nextGapIndex) {
nextGapIndex += lineCountDelta;
}
}

if (nextGap === 0) {
this.resetTailAnchorGap();
return lines;
}

this.tailAnchorGap = nextGap;
this.tailAnchorGapIndex = this.clampTailAnchorGapIndex(lines, nextGapIndex);

return [
...lines.slice(0, this.tailAnchorGapIndex),
...Array.from({ length: this.tailAnchorGap }, () => ""),
...lines.slice(this.tailAnchorGapIndex),
];
}

private normalizeLine(line: string): { line: string; normalized: boolean } {
if (isImageLine(line)) {
return { line, normalized: false };
Expand Down Expand Up @@ -1701,8 +1806,8 @@ export abstract class TuiBase extends Container {
return Array.from({ length: height }, (_, row) => lines[viewportTop + row] ?? "");
}

private shouldPreserveMuxScrollback(): boolean {
return this.#muxDetector() && !useLegacyMuxRender();
private isMuxSession(): boolean {
return this.#muxDetector();
}

private createViewportInsertScrollPlan(
Expand Down Expand Up @@ -1811,7 +1916,7 @@ export abstract class TuiBase extends Container {
): void {
let buffer = TUI.FRAME_BEGIN;
buffer += this.deleteKittyImages(this.previousKittyImageIds);
if (!this.shouldPreserveMuxScrollback()) {
if (this.isMuxSession() && useLegacyMuxRender()) {
buffer += "\x1b[3J";
}

Expand Down Expand Up @@ -2001,7 +2106,13 @@ export abstract class TuiBase extends Container {
}

// Extract cursor position before applying line resets (marker must be found first)
const cursorPos = this.extractCursorPosition(newLines, height);
let cursorPos = this.extractCursorPosition(newLines, height);
const muxSession = this.isMuxSession();
const preserveMuxScrollback = muxSession && !useLegacyMuxRender();
newLines = this.applyTailAnchorGap(newLines, height, !widthChanged && !heightChanged, muxSession);
if (cursorPos && this.tailAnchorGap > 0 && cursorPos.row >= this.tailAnchorGapIndex) {
cursorPos = { row: cursorPos.row + this.tailAnchorGap, col: cursorPos.col };
}

const rawLines = newLines;
const normalizedLines = this.applyViewportLineResets(
Expand All @@ -2011,7 +2122,6 @@ export abstract class TuiBase extends Container {
!widthChanged && !heightChanged,
);
newLines = normalizedLines.lines;
const preserveMuxScrollback = this.shouldPreserveMuxScrollback();

// Helper to clear scrollback and viewport and render all new lines
const fullRender = (clear: boolean, clearScrollback = clear): void => {
Expand Down Expand Up @@ -2149,7 +2259,9 @@ export abstract class TuiBase extends Container {
// No changes - but still need to update hardware cursor position if it moved
if (firstChanged === -1) {
this.positionHardwareCursor(cursorPos, newLines.length);
this.setPreviousLines(newLines, rawLines);
this.previousViewportTop = prevViewportTop;
this.previousWidth = width;
this.previousHeight = height;
return;
}
Expand Down
16 changes: 9 additions & 7 deletions packages/tui/test/sgr-row-clear.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,23 +112,25 @@ describe("TUI row clears reset stale SGR state", () => {
});

it("resets after row clears on scrollback replay", async () => {
const terminal = new LoggingVirtualTerminal(72, 6);
const terminal = new LoggingVirtualTerminal(72, 5);
const tui = new TUI(terminal);
const component = new ExpandableTranscriptComponent();
tui.addChild(component);

component.setExpanded(true);
component.setExpanded(false);
tui.start();
await terminal.waitForRender();
tui.renderNow();
await terminal.flush();
armStaleSgr(terminal);

try {
component.setExpanded(false);
tui.requestRender();
await terminal.waitForRender();
component.setExpanded(true);
tui.renderNow();
await terminal.flush();

const writes = terminal.getWrites();
assert.ok(writes.includes("\x1b[3J"), "scenario should use scrollback replay");
assert.ok(writes.includes("session title"), "scenario should use scrollback replay");
assert.ok(!writes.includes("\x1b[3J"), "scrollback replay should preserve existing history");
assertEveryRowClearResets(writes, "scrollback replay");
} finally {
tui.stop();
Expand Down
Loading