diff --git a/docs/TUI.md b/docs/TUI.md index f4dab17a3..1f90fc223 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -542,7 +542,7 @@ landing screen at, say, 23 rows gets an 8-row cap instead of 9. This is a known, accepted cost of the badge rather than an oversight — see `terminalForGeometry`'s doc comment in `src/tui/shell/layout.ts` for the exact mechanism. -While the landing is mounted, a mount-scoped 125ms timer advances snow +While the landing is mounted, a mount-scoped 500ms idle-repaint timer advances snow across a frozen mountain. It is cancelled on the first real transcript row or on shell dispose. Deferred system notices do not count. While the landing is still up, the callback no-ops if a turn is already diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index 280c622d4..b8f9fccc0 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -20,7 +20,7 @@ import { toggleTasksPanel, } from "./shell/chrome"; import { createAppShell } from "./shell/index"; -import { isLanding } from "./shell/internals"; +import { isLanding, shellInternals } from "./shell/internals"; import { setPromptModelLabel, setPromptWorkspace, @@ -32,6 +32,7 @@ import { LANDING_HINTS, LANDING_SUGGESTIONS, LANDING_VERSION, + fitLandingMark, landingBelowContent, landingBelowRows, landingSuggestionFor, @@ -104,6 +105,57 @@ function soleLandingIdleHandle( return handle; } +interface CapturedIdleTimer { + handle: IdleTimerHandle; + handler: () => void; + delay: number; +} + +/** + * Captures the product idle interval's handler so tests can fire the tick + * deterministically instead of waiting out the wall clock. Like + * `wrapLandingIdleTimer`, it never arms a real interval. + */ +function captureLandingIdleTimer(): { + captured: CapturedIdleTimer[]; + cleared: IdleTimerHandle[]; +} { + const captured: CapturedIdleTimer[] = []; + const cleared: IdleTimerHandle[] = []; + globalThis.setInterval = (( + handler: Parameters[0], + delay?: number, + ...args: unknown[] + ) => { + if (delay === LANDING_IDLE_REPAINT_INTERVAL_MS) { + const handle: IdleTimerHandle = {}; + captured.push({ handle, handler: handler as () => void, delay }); + return handle; + } + return nativeSetInterval.call(globalThis, handler, delay, ...args); + }) as typeof nativeSetInterval; + globalThis.clearInterval = (( + handle: Parameters[0], + ) => { + cleared.push(handle as IdleTimerHandle); + if (captured.some((entry) => entry.handle === handle)) return; + return nativeClearInterval.call(globalThis, handle); + }) as typeof nativeClearInterval; + return { captured, cleared }; +} + +function soleCapturedIdleTimer( + captured: readonly CapturedIdleTimer[], +): CapturedIdleTimer { + const entry = captured[0]; + if (captured.length !== 1 || entry === undefined) { + throw new Error( + `expected exactly one ${LANDING_IDLE_REPAINT_INTERVAL_MS}ms interval, got ${captured.length}`, + ); + } + return entry; +} + /** Newly added scroll-box children need a layout pass before they paint. */ async function settle(h: Harness): Promise { await h.renderOnce(); @@ -455,6 +507,79 @@ describe("landing screen", () => { } }, SIZE); }); + + test("the idle timer arms at a 500ms-or-slower cadence", async () => { + const { captured } = captureLandingIdleTimer(); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + run: "idle", + wireKeys: false, + terminal: { columns: 80, rows: 24 }, + }); + try { + const entry = soleCapturedIdleTimer(captured); + expect(LANDING_IDLE_REPAINT_INTERVAL_MS).toBeGreaterThanOrEqual(500); + expect(entry.delay).toBeGreaterThanOrEqual(500); + } finally { + shell.dispose(); + } + }, SIZE); + }); + + test("an idle tick with no mark grid writes no rows but keeps the timer armed", async () => { + const { captured, cleared } = captureLandingIdleTimer(); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + run: "idle", + wireKeys: false, + terminal: { columns: 80, rows: 24 }, + }); + try { + const entry = soleCapturedIdleTimer(captured); + const bag = defined(shellInternals(shell)); + // Narrow-terminal composition: the mark is suppressed (hints only), + // so no row could change and the tick must skip the full repaint. + fitLandingMark(defined(bag.landing).above, null); + await settle(h); + const before = markRows(h).join("\n"); + + entry.handler(); + await settle(h); + + expect(bag.landingNowMs).toBe(0); + expect(markRows(h).join("\n")).toBe(before); + expect(cleared).not.toContain(entry.handle); + } finally { + shell.dispose(); + } + }, SIZE); + }); + + test("an idle tick after the landing clears repaints nothing", async () => { + const { captured, cleared } = captureLandingIdleTimer(); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + run: "idle", + wireKeys: false, + terminal: { columns: 80, rows: 24 }, + }); + try { + const entry = soleCapturedIdleTimer(captured); + appendStreamRow(shell, { role: "user", text: "first prompt" }); + expect(isLanding(shell)).toBe(false); + expect(cleared).toContain(entry.handle); + const bag = defined(shellInternals(shell)); + const stamp = bag.landingNowMs; + + entry.handler(); + await settle(h); + + expect(bag.landingNowMs).toBe(stamp); + } finally { + shell.dispose(); + } + }, SIZE); + }); }); test("a starter key fills the prompt; a typed prompt keeps its digits", async () => { diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index cb21cc24f..d1d96c3fd 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -1385,10 +1385,10 @@ function clearLandingMark(shell: AppShell): void { /** * Cadence of the mount-scoped idle repaint timer armed in `createAppShell`. - * The snow only needs to advance about half a row per second, so 8fps is - * comfortably enough to read as motion. + * The snow falls about half a row per second, so a 2fps tick still reads as + * motion while cutting idle full-hero repaints roughly fourfold. */ -export const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; +export const LANDING_IDLE_REPAINT_INTERVAL_MS = 500; /** * Repaint the landing mark for `nowMs`. `animating` runs the mountain's diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index 7379385c6..43a154e56 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -578,6 +578,11 @@ export function createAppShell( } const bag = shellInternals(shell); if (bag?.landing == null || bag.landingAnimating) return; + // Positively-dirty gate: a null grid suppresses the mark entirely + // (narrow terminal, hints only), so no row would change — skip the + // full-hero repaint. A live grid always counts dirty: the snow tick + // advances flakes every frame, so there is no unset-flag freeze. + if (bag.landing.above.grid === null) return; paintLanding(shell, Date.now(), false); }, LANDING_IDLE_REPAINT_INTERVAL_MS); landingIdleHandle.unref?.();