fix(tui): rendered WSL streaming output without resize trigger

`requiresNativeViewportProofForReplay` flagged WSL+Windows-Terminal
unknown viewport state as "scrolled into history", but
`ProcessTerminal.isNativeViewportAtBottom` can only answer through
`kernel32.dll` FFI — unreachable from a Linux user-space process inside
WSL — so the probe was permanently `undefined`. Every structural
mutation (each row inserted above the bottom-anchored prompt during
streaming) became `deferredMutation` and emitted zero bytes; only a
geometry change re-routed through a different intent, which is why
minimize+restore made the output appear.

Drop the WSL clause: when the probe cannot answer, treat unknown as
at-bottom (pre-15.5.14 behaviour) so the live render path runs again.
Native Win32 keeps the conservative "assume scrolled when unknown"
heuristic since `kernel32` FFI does succeed there.

Tests:
- new `renders streaming row inserts on WSL Windows Terminal even when
  viewport probe is unavailable` regression in `render-regressions.test.ts`
- delete the two tests added in fd346358c that pinned the buggy
  WSL-as-scrolled contract

Fixes #1534
This commit is contained in:
roboomp
2026-05-30 08:31:26 +00:00
parent 304a9346e9
commit 191c844986
3 changed files with 51 additions and 87 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed streaming output staying invisible in Windows Terminal + WSL2 until the window was minimized + restored. The 15.5.14 WSL branch of `requiresNativeViewportProofForReplay` treated an unknown native viewport state as "scrolled into history" — but `ProcessTerminal.isNativeViewportAtBottom` can only return a real answer through `kernel32.dll` FFI, which a Linux user-space process inside WSL cannot load, so the probe was permanently `undefined`. Every row-inserting structural mutation (each new streaming token row above the bottom-anchored prompt) was therefore classified as `deferredMutation` and emitted zero bytes. Any geometry change (resize/minimize/restore) bypassed the gate via a different render intent, which is why the output became visible only on window resize. The WSL clause is removed; on platforms where the probe cannot answer, unknown is treated as at-bottom (the pre-15.5.14 behaviour) so the live render path runs again. Native Win32 keeps the conservative "assume scrolled when unknown" heuristic since `kernel32` FFI does succeed there and unknown means the probe transiently failed. ([#1534](https://github.com/can1357/oh-my-pi/issues/1534))
## [15.5.14] - 2026-05-29
### Added
+2 -11
View File
@@ -154,15 +154,6 @@ function isMultiplexerSession(): boolean {
return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
}
function requiresNativeViewportProofForReplay(): boolean {
return (
process.platform === "win32" ||
(process.platform === "linux" &&
Boolean(Bun.env.WT_SESSION) &&
Boolean(Bun.env.WSL_DISTRO_NAME || Bun.env.WSL_INTEROP))
);
}
/**
* Options for overlay positioning and sizing.
* Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -1442,7 +1433,7 @@ export class TUI extends Container {
#nativeViewportIsScrolled(nativeViewportAtBottom: boolean | undefined): boolean {
return (
nativeViewportAtBottom === false ||
(nativeViewportAtBottom === undefined && requiresNativeViewportProofForReplay())
(nativeViewportAtBottom === undefined && process.platform === "win32")
);
}
@@ -1456,7 +1447,7 @@ export class TUI extends Container {
): boolean {
return (
nativeViewportAtBottom === true ||
(nativeViewportAtBottom === undefined && (allowUnknownViewport || !requiresNativeViewportProofForReplay()))
(nativeViewportAtBottom === undefined && (allowUnknownViewport || process.platform !== "win32"))
);
}
+45 -76
View File
@@ -1149,91 +1149,60 @@ describe("TUI terminal-state regressions", () => {
}
});
it("treats unknown WSL Windows Terminal viewport state as scrolled", async () => {
it("renders streaming row inserts on WSL Windows Terminal even when viewport probe is unavailable", async () => {
const originalPlatform = process.platform;
const originalWtSession = Bun.env.WT_SESSION;
const originalWslDistroName = Bun.env.WSL_DISTRO_NAME;
const originalWslInterop = Bun.env.WSL_INTEROP;
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
Bun.env.WT_SESSION = "wt-test";
Bun.env.WSL_DISTRO_NAME = "Ubuntu";
delete Bun.env.WSL_INTEROP;
const term = new UnknownViewportTerminal(32, 5);
const tui = new TUI(term);
const component = new MutableLinesComponent(rows("line-", 12));
tui.addChild(component);
await withEnvPatch(
{ WT_SESSION: "wt-test", WSL_DISTRO_NAME: "Ubuntu", WSL_INTEROP: undefined },
async () => {
// Simulate WSL: native viewport probe returns undefined unconditionally
// (kernel32.dll FFI cannot bind from a Linux user-space process).
const term = new UnknownViewportTerminal(32, 5);
const tui = new TUI(term);
// Bottom-anchored footer (prompt area) with streaming assistant rows above it.
// Seed the transcript so the viewport is already saturated — the footer pins
// to the last viewport row and streamed rows must appear above it.
const transcript = new MutableLinesComponent(rows("seed-", 4));
const footer = new MutableLinesComponent(["prompt>"]);
tui.addChild(transcript);
tui.addChild(footer);
try {
tui.start();
await settle(term);
term.scrollLines(-2);
const before = term.getBufferPosition();
expect(before.viewportY).toBeGreaterThan(0);
try {
tui.start();
await settle(term);
expect(visible(term).map(line => line.trim())).toEqual([
"seed-0",
"seed-1",
"seed-2",
"seed-3",
"prompt>",
]);
component.setLines(rows("line-", 8));
tui.requestRender();
await settle(term);
// Stream tokens row-by-row. Each frame inserts a new row above the footer,
// mimicking an assistant response materializing during a turn.
for (let i = 0; i < 4; i++) {
transcript.setLines([...rows("seed-", 4), ...rows("token-", i + 1)]);
tui.requestRender();
await settle(term);
const after = term.getBufferPosition();
expect(after.viewportY).toBe(before.viewportY);
expect(visible(term).map(line => line.trim())).toEqual(["line-5", "line-6", "line-7", "", ""]);
expect(tui.refreshNativeScrollbackIfDirty()).toBe(false);
expect(term.getBufferPosition().viewportY).toBe(before.viewportY);
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
if (originalWtSession === undefined) delete Bun.env.WT_SESSION;
else Bun.env.WT_SESSION = originalWtSession;
if (originalWslDistroName === undefined) delete Bun.env.WSL_DISTRO_NAME;
else Bun.env.WSL_DISTRO_NAME = originalWslDistroName;
if (originalWslInterop === undefined) delete Bun.env.WSL_INTEROP;
else Bun.env.WSL_INTEROP = originalWslInterop;
tui.stop();
}
const viewport = visible(term).map(line => line.trim());
// The most recently streamed token MUST land in the viewport without the
// user resizing the window. Pre-fix the viewport stayed frozen at the
// initial seed because deferredMutation returned a no-op render.
expect(viewport).toContain(`token-${i}`);
expect(viewport[viewport.length - 1]).toBe("prompt>");
}
} finally {
tui.stop();
}
},
);
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
});
it("refreshes unknown WSL Windows Terminal scrollback at a submit checkpoint", async () => {
const originalPlatform = process.platform;
const originalWtSession = Bun.env.WT_SESSION;
const originalWslDistroName = Bun.env.WSL_DISTRO_NAME;
const originalWslInterop = Bun.env.WSL_INTEROP;
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
Bun.env.WT_SESSION = "wt-test";
Bun.env.WSL_DISTRO_NAME = "Ubuntu";
delete Bun.env.WSL_INTEROP;
const term = new UnknownViewportTerminal(32, 5);
const tui = new TUI(term);
const component = new MutableLinesComponent(rows("line-", 12));
tui.addChild(component);
try {
tui.start();
await settle(term);
term.scrollLines(-2);
component.setLines(rows("line-", 8));
tui.requestRender();
await settle(term);
term.scrollLines(999);
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
await settle(term);
const position = term.getBufferPosition();
expect(position.viewportY).toBe(position.baseY);
expect(visible(term).map(line => line.trim())).toEqual(["line-3", "line-4", "line-5", "line-6", "line-7"]);
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
if (originalWtSession === undefined) delete Bun.env.WT_SESSION;
else Bun.env.WT_SESSION = originalWtSession;
if (originalWslDistroName === undefined) delete Bun.env.WSL_DISTRO_NAME;
else Bun.env.WSL_DISTRO_NAME = originalWslDistroName;
if (originalWslInterop === undefined) delete Bun.env.WSL_INTEROP;
else Bun.env.WSL_INTEROP = originalWslInterop;
tui.stop();
}
});
it("refreshes deferred native scrollback when the native viewport reaches bottom", async () => {
const term = new VirtualTerminal(32, 5);
const tui = new TUI(term);