Skip to content
Merged
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
54 changes: 54 additions & 0 deletions src/tui/hooks/use-terminal-size.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
26 changes: 24 additions & 2 deletions src/tui/hooks/use-terminal-size.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand All @@ -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());
}
97 changes: 97 additions & 0 deletions src/tui/legacy-conhost.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
90 changes: 90 additions & 0 deletions src/tui/legacy-conhost.ts
Original file line number Diff line number Diff line change
@@ -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)"
);
}
11 changes: 11 additions & 0 deletions src/tui/tui-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -665,6 +666,16 @@ export async function tuiCommand(args: string[]): Promise<number> {
});
}

// 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
Expand Down
Loading