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:
@@ -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
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user