From aade616bddbf83e9c2eaa96ed8a74c8e150694cb Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Mon, 31 Aug 2026 17:19:49 +0300 Subject: [PATCH] fix(tui): reserve the bottom row on legacy Win10 conhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.4.2 synchronized-update bracketing (eb690e7) stopped the frame tearing but not the Win10 conhost reports: residual shaking, and a duplicated last row under PowerShell. Both fit one mechanism the bracketing cannot address — the TUI pins its root to `height={rows}`, and the frozen inbox conhost scrolls when a full-height frame writes into its bottom row; DEC 2026 is ignored there, and a scroll is buffer movement, not tearing. Once the viewport slides one line, the repaint cursor math is off by one: the UI shakes and the row that scrolled away leaves the last row painted twice. No Windows machine was available to reproduce, so the change is the conservative guard: when the host looks like a legacy conhost (win32, and neither WT_SESSION nor TERM_PROGRAM set), `useTerminalSize` reports one row fewer, so no frame ever touches the bottom terminal row and there is nothing left to scroll. Every modern host — Windows Terminal, VS Code, anything setting those variables — keeps the full height, and non-TTY streams (tests, pipes, CI) are untouched. A one-time transcript hint on such consoles recommends Windows Terminal and names the escape hatch: ATOMIC_AGENT_CONHOST_GUARD=0 disables the guard, =1 forces it on anywhere — which is also how it was verified: a PTY+pyte run at 80x24 shows a 23-row frame, a never-written bottom row, and the hint; with the variable unset the frame is unchanged from main. Co-Authored-By: Claude Fable 5 --- src/tui/hooks/use-terminal-size.test.ts | 54 ++++++++++++++ src/tui/hooks/use-terminal-size.ts | 26 ++++++- src/tui/legacy-conhost.test.ts | 97 +++++++++++++++++++++++++ src/tui/legacy-conhost.ts | 90 +++++++++++++++++++++++ src/tui/tui-command.ts | 11 +++ 5 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 src/tui/hooks/use-terminal-size.test.ts create mode 100644 src/tui/legacy-conhost.test.ts create mode 100644 src/tui/legacy-conhost.ts diff --git a/src/tui/hooks/use-terminal-size.test.ts b/src/tui/hooks/use-terminal-size.test.ts new file mode 100644 index 00000000..8e0f195f --- /dev/null +++ b/src/tui/hooks/use-terminal-size.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { readTerminalSize } from "./use-terminal-size.js"; + +const tty = (columns: number, rows: number): NodeJS.WriteStream => + ({ columns, rows, isTTY: true }) as unknown as NodeJS.WriteStream; + +describe("readTerminalSize", () => { + it("reports the raw TTY size on a modern host", () => { + expect(readTerminalSize(tty(120, 40), false)).toEqual({ + columns: 120, + rows: 40, + }); + }); + + it("reserves the bottom row on a legacy conhost TTY", () => { + // The frame is pinned to `height={rows}`; on the frozen Win10 + // conhost a frame that touches the bottom terminal row scrolls the + // viewport — the duplicated-last-row / shaking report. One reserved + // row keeps every frame strictly above the scroll trigger. + expect(readTerminalSize(tty(120, 40), true)).toEqual({ + columns: 120, + rows: 39, + }); + }); + + it("leaves non-TTY streams alone even when detection says conhost", () => { + const piped = { + columns: 100, + rows: 30, + isTTY: false, + } as unknown as NodeJS.WriteStream; + expect(readTerminalSize(piped, true)).toEqual({ columns: 100, rows: 30 }); + }); + + it("falls back to 80x24 without a stream, guard or not", () => { + expect(readTerminalSize(undefined, false)).toEqual({ + columns: 80, + rows: 24, + }); + // No stream means no TTY, so the guard cannot apply either. + expect(readTerminalSize(undefined, true)).toEqual({ + columns: 80, + rows: 24, + }); + }); + + it("does not let the guard report a zero-height terminal", () => { + expect(readTerminalSize(tty(80, 1), true)).toEqual({ + columns: 80, + rows: 1, + }); + }); +}); diff --git a/src/tui/hooks/use-terminal-size.ts b/src/tui/hooks/use-terminal-size.ts index 57ef0932..e1574cab 100644 --- a/src/tui/hooks/use-terminal-size.ts +++ b/src/tui/hooks/use-terminal-size.ts @@ -1,6 +1,11 @@ import { useStdout } from "ink"; import { useEffect, useState } from "react"; +import { + clampRowsForLegacyConhost, + isLegacyConhost, +} from "../legacy-conhost.js"; + export interface TerminalSize { columns: number; rows: number; @@ -18,6 +23,10 @@ const DEFAULT_ROWS = 24; * * The hook only listens while mounted — the listener is detached on * unmount to avoid leaking handlers into long-running processes. + * + * On a legacy Win10 conhost the reported height is one row short of the + * real terminal: a full-height frame scrolls that console, which is the + * "shaking" / duplicated-last-row report. See `legacy-conhost.ts`. */ export function useTerminalSize(): TerminalSize { const { stdout } = useStdout(); @@ -35,8 +44,21 @@ export function useTerminalSize(): TerminalSize { return size; } -function readSize(stdout: NodeJS.WriteStream | undefined): TerminalSize { +/** + * Pure size read, exported for tests. The legacy-conhost row guard only + * applies to a real TTY: the fake stdouts used by tests, pipes and CI + * have no scrolling cursor, and their reported size is kept verbatim. + */ +export function readTerminalSize( + stdout: NodeJS.WriteStream | undefined, + legacyConhost: boolean, +): TerminalSize { const columns = stdout?.columns ?? DEFAULT_COLUMNS; const rows = stdout?.rows ?? DEFAULT_ROWS; - return { columns, rows }; + const guard = legacyConhost && stdout?.isTTY === true; + return { columns, rows: clampRowsForLegacyConhost(rows, guard) }; +} + +function readSize(stdout: NodeJS.WriteStream | undefined): TerminalSize { + return readTerminalSize(stdout, isLegacyConhost()); } diff --git a/src/tui/legacy-conhost.test.ts b/src/tui/legacy-conhost.test.ts new file mode 100644 index 00000000..87ba8a46 --- /dev/null +++ b/src/tui/legacy-conhost.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { + clampRowsForLegacyConhost, + isLegacyConhost, + legacyConhostStartupHint, +} from "./legacy-conhost.js"; + +describe("isLegacyConhost", () => { + it("detects a bare conhost environment on Windows", () => { + expect(isLegacyConhost({ platform: "win32", env: {} })).toBe(true); + }); + + it("is false under Windows Terminal (WT_SESSION)", () => { + expect( + isLegacyConhost({ + platform: "win32", + env: { WT_SESSION: "a-guid" }, + }), + ).toBe(false); + }); + + it("is false under hosts that set TERM_PROGRAM (VS Code, mintty)", () => { + expect( + isLegacyConhost({ + platform: "win32", + env: { TERM_PROGRAM: "vscode" }, + }), + ).toBe(false); + }); + + it("is false everywhere that is not Windows", () => { + expect(isLegacyConhost({ platform: "darwin", env: {} })).toBe(false); + expect(isLegacyConhost({ platform: "linux", env: {} })).toBe(false); + }); + + it("treats empty marker variables as absent", () => { + expect( + isLegacyConhost({ + platform: "win32", + env: { WT_SESSION: "", TERM_PROGRAM: "" }, + }), + ).toBe(true); + }); + + it("ATOMIC_AGENT_CONHOST_GUARD=1 forces the guard on anywhere", () => { + expect( + isLegacyConhost({ + platform: "darwin", + env: { ATOMIC_AGENT_CONHOST_GUARD: "1" }, + }), + ).toBe(true); + }); + + it("ATOMIC_AGENT_CONHOST_GUARD=0 forces the guard off on a conhost", () => { + expect( + isLegacyConhost({ + platform: "win32", + env: { ATOMIC_AGENT_CONHOST_GUARD: "0" }, + }), + ).toBe(false); + }); +}); + +describe("clampRowsForLegacyConhost", () => { + it("reserves exactly one row on a legacy conhost", () => { + expect(clampRowsForLegacyConhost(24, true)).toBe(23); + expect(clampRowsForLegacyConhost(50, true)).toBe(49); + }); + + it("keeps the full height everywhere else", () => { + expect(clampRowsForLegacyConhost(24, false)).toBe(24); + }); + + it("never reports less than one row", () => { + expect(clampRowsForLegacyConhost(1, true)).toBe(1); + expect(clampRowsForLegacyConhost(0, true)).toBe(1); + }); +}); + +describe("legacyConhostStartupHint", () => { + it("recommends Windows Terminal on a legacy conhost", () => { + const hint = legacyConhostStartupHint({ platform: "win32", env: {} }); + expect(hint).toContain("Windows Terminal"); + expect(hint).toContain("ATOMIC_AGENT_CONHOST_GUARD=0"); + }); + + it("stays silent on a modern host", () => { + expect( + legacyConhostStartupHint({ + platform: "win32", + env: { WT_SESSION: "a-guid" }, + }), + ).toBeNull(); + expect(legacyConhostStartupHint({ platform: "darwin", env: {} })).toBeNull(); + }); +}); diff --git a/src/tui/legacy-conhost.ts b/src/tui/legacy-conhost.ts new file mode 100644 index 00000000..dde3774c --- /dev/null +++ b/src/tui/legacy-conhost.ts @@ -0,0 +1,90 @@ +/** + * Legacy Windows console (conhost) detection, and the one-row guard + * that keeps full-height frames from scrolling it. + * + * The TUI pins its root box to `height={rows}` (see `tui-app.tsx`), so + * every frame is exactly as tall as the terminal. That is safe on a + * VT terminal that defers the end-of-line wrap: painting the last cell + * of the last row leaves the cursor parked, and nothing scrolls. The + * frozen conhost that ships inside Windows 10 is the terminal where + * that guarantee has never held — a write that lands on the bottom + * row can push the viewport up one line, after which the repaint's + * cursor math is off by one: the whole UI "shakes", and the row that + * scrolled away leaves the last row painted twice. Both symptoms are + * the Win10 reports against v0.4.1/v0.4.2 (cmd and PowerShell — the + * shell does not matter, the conhost window hosting it does). + * + * The synchronized-update bracketing (`synchronized-output.ts`) cannot + * help here: conhost ignores DEC 2026, and the scroll is real movement + * of the buffer, not tearing. + * + * So: when the host is a *legacy* conhost, report one row fewer to the + * layout. No frame ever touches the bottom terminal row, so there is + * nothing left to scroll. The cost is one blank row, paid only on the + * one console that cannot be fixed (Win10's inbox conhost is frozen; + * Windows Terminal ships the maintained fork). + * + * Detection is deliberately narrow — Windows, and neither of the two + * variables every modern host sets: + * - `WT_SESSION` — Windows Terminal + * - `TERM_PROGRAM` — VS Code, mintty, and friends + * A plain cmd/PowerShell window on Win10 sets neither. + * + * `ATOMIC_AGENT_CONHOST_GUARD=0` turns the guard off where it + * misfires; `=1` forces it on anywhere, which is how the behaviour is + * verified from a terminal that is not a conhost. + */ + +export interface LegacyConhostOptions { + /** Env source for detection + override; injectable for tests. */ + readonly env?: NodeJS.ProcessEnv; + /** Platform under test; defaults to the live `process.platform`. */ + readonly platform?: NodeJS.Platform; +} + +/** + * True when stdout is (best guess) the frozen Win10 conhost rather + * than a modern VT host. See the module comment for the reasoning and + * the `ATOMIC_AGENT_CONHOST_GUARD` override. + */ +export function isLegacyConhost(options: LegacyConhostOptions = {}): boolean { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const override = env.ATOMIC_AGENT_CONHOST_GUARD; + if (override === "1") return true; + if (override === "0") return false; + if (platform !== "win32") return false; + if (env.WT_SESSION) return false; + if (env.TERM_PROGRAM) return false; + return true; +} + +/** + * The row budget the layout may actually use. One row is reserved on a + * legacy conhost so no frame reaches the terminal's bottom row; every + * other host keeps the full height. Never returns less than 1. + */ +export function clampRowsForLegacyConhost( + rows: number, + legacyConhost: boolean, +): number { + if (!legacyConhost) return rows; + return Math.max(1, rows - 1); +} + +/** + * The one-time startup line for the transcript, or `null` off a legacy + * conhost. Worded as a recommendation, not an error: the guard already + * has the rendering handled — this is where the operator learns that a + * better console exists. + */ +export function legacyConhostStartupHint( + options: LegacyConhostOptions = {}, +): string | null { + if (!isLegacyConhost(options)) return null; + return ( + "legacy Windows console detected — the bottom row is kept clear to " + + "avoid scroll glitches; Windows Terminal (`wt`) renders this UI " + + "properly (ATOMIC_AGENT_CONHOST_GUARD=0 disables the guard)" + ); +} diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 8323c672..ae5cfeeb 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -18,6 +18,7 @@ import { isKnownLocalModelId } from "../local-llm/index.js"; import { registerSession } from "../local-llm/session-registry.js"; import { enterAltScreen } from "./alt-screen.js"; import { enableSynchronizedOutput } from "./synchronized-output.js"; +import { legacyConhostStartupHint } from "./legacy-conhost.js"; import { ChatOrchestrator } from "./chat-orchestrator.js"; import { parseTuiArgs, nonInteractiveStdinError, @@ -665,6 +666,16 @@ export async function tuiCommand(args: string[]): Promise { }); } + // The frozen Win10 conhost scrolls under full-height frames — the + // layout already reserves its bottom row (see `legacy-conhost.ts`); + // this is where the operator learns why, and that Windows Terminal + // does not need the workaround. Once per session, in the transcript, + // because anything printed before the alt screen is never seen. + const conhostHint = legacyConhostStartupHint(); + if (conhostHint) { + bus.emit({ type: "system_message", text: conhostHint }); + } + // If the user is in managed mode and the backend + model are ready // on disk, start the daemon immediately so there is no extra // "run this command in another terminal" step. No-op in external