diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6aedbe581..d8c43c01a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 7c7a841a0..4c4720e70 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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")) ); } diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index dff27ac9b..35587a17a 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -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);