From 60ead0320e44909b9320dbac668d31f60feaa365 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 11:50:25 -0700 Subject: [PATCH 1/8] Document that the landing clocks pre-session snow The monitor stays quiet when idle, which is correct for turn chrome. The landing is idle by definition and still wants motion, so it owns a mount-scoped timer for its lifetime. Driving the mark from the renderer FRAME event was rejected because FRAME follows dirty rows and self-starves under throttle. --- docs/TUI.md | 20 ++++++++++++++++++-- src/tui/mark-anim.ts | 4 ++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 8a8dd3d12..a5598f2b9 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -155,8 +155,10 @@ The notice is a live diagnosis, not a sticky banner: it comes down on the same paint as the activity that ends the silence, including when the turn settles before the next monitor tick. -An idle session animates nothing at all: the monitor tick stops entirely -rather than repainting an unchanging frame. +An idle session's turn chrome animates nothing at all: the monitor tick +stops entirely rather than repainting an unchanging frame. Pre-session +motion belongs to the landing's mount lifetime, not the monitor (see the +idle landing below). Color is a small, deliberate palette, not decoration (`src/tui/theme.ts`). Dimmed text is a dimmed cream, never a neutral @@ -427,6 +429,20 @@ 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 `shell.ts` for the exact mechanism. +While the landing is mounted, a mount-scoped ~8 fps timer +(`LANDING_IDLE_REPAINT_INTERVAL_MS` in `src/tui/shell.ts`) advances snow +across a frozen mountain. It is cancelled on the first real transcript +row (`clearLandingMark`) or on shell dispose, and does not run during a +session. `still` freezes the mountain's draw/fill/fade timeline only; +snow still drifts on the idle landing. Reduced-motion, when a setting +exists, suppresses snow via `reducedMotion` on `renderMark` — not via +`still`. There is no live TUI setting yet. + +The mark is not driven from the renderer's FRAME event: FRAME follows +dirty rows, not a clock, so that path self-starves under throttle. +Keeping the monitor ticking on idle would mix turn-chrome cadence into a +pre-session surface; the monitor idle-stop stays correct for turn chrome. + The model/provider picker is one flat, type-to-filter list (`src/tui/product-host.ts` + `openModelPickerOverlay({ typeToFilter: true })`): recent and favorite provider+model pairs sit at the top, then every diff --git a/src/tui/mark-anim.ts b/src/tui/mark-anim.ts index 16d0ecfe4..5532557c3 100644 --- a/src/tui/mark-anim.ts +++ b/src/tui/mark-anim.ts @@ -16,8 +16,8 @@ * that does suppress snow. Mountain cells always win over flakes. * * Everything here is pure and clock-injected: `nowMs` is the only time source, - * so the caller's existing 250 ms status tick drives the animation and tests - * drive it deterministically. There is no timer in this module. + * so tests drive it deterministically. The idle caller is the landing timer; + * the turn caller is the monitor. There is no timer in this module. */ import { MARK_SMALL, type MarkGrid } from "./mark-shape.js"; From 25889161caadf79211f80a81cc7f643668de64ef Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 11:50:31 -0700 Subject: [PATCH 2/8] Pin idle landing snow through the mount timer Paint-function tests stayed green while snow was unreachable. These assert the real mount timer, its teardown, and the reduced-motion hook. --- src/tui/landing.test.ts | 84 ++++++++++++++++++++++++++++++++++++++- src/tui/mark-anim.test.ts | 23 +++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index f8c3d7c19..4e7284218 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -3,7 +3,7 @@ * telemetry disclosure and selectable starters — and nothing left over once * the transcript has content. */ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import type { CapturedSpan } from "@opentui/core"; import { rgbToHex } from "@opentui/core"; import { withTestRenderer, type Harness } from "./harness"; @@ -47,6 +47,54 @@ import { UI } from "./theme"; const SIZE = { width: 80, height: 24 } as const; const NOTICE = "Anonymous usage telemetry is enabled. Disable in /settings."; +const nativeSetInterval = globalThis.setInterval; +const nativeClearInterval = globalThis.clearInterval; + +afterEach(() => { + globalThis.setInterval = nativeSetInterval; + globalThis.clearInterval = nativeClearInterval; +}); + +/** + * 125 is `LANDING_IDLE_REPAINT_INTERVAL_MS` in shell.ts. Hardcoded so a + * cadence change fails these tests on purpose rather than tracking a product + * export. + */ +const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; + +type IntervalHandle = ReturnType; + +/** Wrap globals; the original handle is returned so `unref` still exists. */ +function wrapLandingIdleTimer(): { + armed: IntervalHandle[]; + cleared: unknown[]; +} { + const armed: IntervalHandle[] = []; + const cleared: unknown[] = []; + globalThis.setInterval = (( + handler: Parameters[0], + delay?: number, + ...args: unknown[] + ) => { + const handle = nativeSetInterval.call(globalThis, handler, delay, ...args); + if (delay === LANDING_IDLE_REPAINT_INTERVAL_MS) armed.push(handle); + return handle; + }) as typeof nativeSetInterval; + globalThis.clearInterval = ((handle: Parameters[0]) => { + cleared.push(handle); + return nativeClearInterval.call(globalThis, handle); + }) as typeof nativeClearInterval; + return { armed, cleared }; +} + +function soleLandingIdleHandle(armed: readonly IntervalHandle[]): IntervalHandle { + const handle = armed[0]; + if (armed.length !== 1 || handle === undefined) { + throw new Error(`expected exactly one 125ms interval, got ${String(armed.length)}`); + } + return handle; +} + /** Newly added scroll-box children need a layout pass before they paint. */ async function settle(h: Harness): Promise { await h.renderOnce(); @@ -307,6 +355,40 @@ describe("landing screen", () => { }, SIZE); }, 15_000); + test("appending a transcript row clears the landing idle timer", async () => { + const { armed, cleared } = wrapLandingIdleTimer(); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + run: "idle", + wireKeys: false, + terminal: { columns: 80, rows: 24 }, + }); + try { + const handle = soleLandingIdleHandle(armed); + appendStreamRow(shell, { role: "user", text: "first prompt" }); + expect(isLanding(shell)).toBe(false); + expect(cleared).toContain(handle); + await settle(h); + } finally { + shell.dispose(); + } + }, SIZE); + }); + + test("disposing the shell with no transcript clears the landing idle timer", async () => { + const { armed, cleared } = wrapLandingIdleTimer(); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + run: "idle", + wireKeys: false, + terminal: { columns: 80, rows: 24 }, + }); + const handle = soleLandingIdleHandle(armed); + shell.dispose(); + expect(cleared).toContain(handle); + }, SIZE); + }); + test("a starter key fills the prompt; a typed prompt keeps its digits", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { diff --git a/src/tui/mark-anim.test.ts b/src/tui/mark-anim.test.ts index 6015d458f..5847eb899 100644 --- a/src/tui/mark-anim.test.ts +++ b/src/tui/mark-anim.test.ts @@ -135,6 +135,29 @@ describe("renderMark", () => { expect(new Set(withSnow).size).toBeGreaterThan(1); }); + test("reducedMotion drops snow at a clock that otherwise snows, without reshaping the mountain", () => { + const times = [0, 1500, 3000, 4500, 6000, 7500]; + const nowMs = times.find((t) => + renderMark({ nowMs: t, still: true, reducedMotion: false, grid: MARK_LARGE }) + .flat() + .some((cell) => isSnow(cell.char)), + ); + if (nowMs === undefined) { + throw new Error("expected a still-mode clock that draws snow"); + } + + const snowing = renderMark({ nowMs, still: true, reducedMotion: false, grid: MARK_LARGE }); + const quiet = renderMark({ nowMs, still: true, reducedMotion: true, grid: MARK_LARGE }); + expect(snowing.flat().some((cell) => isSnow(cell.char))).toBe(true); + expect(quiet.flat().some((cell) => isSnow(cell.char))).toBe(false); + + const mountainOnly = (grid: typeof snowing) => + grid + .map((row) => row.map((cell) => (isMountain(cell.char) ? cell.char : " ")).join("")) + .join("\n"); + expect(mountainOnly(quiet)).toBe(mountainOnly(snowing)); + }); + test("the animated frame advances with the injected clock", () => { const frames = [0, 400, 900, 1500, 2400, 3200].map((nowMs) => markText(renderMark({ nowMs, still: false })), From 4f63289669f6095c087549610731382c644613a7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 12:21:37 -0700 Subject: [PATCH 3/8] Tighten landing snow tests and TUI spec --- docs/TUI.md | 25 ++++---- src/tui/landing.test.ts | 117 ++++++++++++++++++++++++++------------ src/tui/mark-anim.test.ts | 37 ++++++++---- src/tui/mark-anim.ts | 17 ++---- src/tui/shell.ts | 17 +++--- 5 files changed, 131 insertions(+), 82 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index a5598f2b9..6d04f78c2 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -429,19 +429,18 @@ 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 `shell.ts` for the exact mechanism. -While the landing is mounted, a mount-scoped ~8 fps timer -(`LANDING_IDLE_REPAINT_INTERVAL_MS` in `src/tui/shell.ts`) advances snow -across a frozen mountain. It is cancelled on the first real transcript -row (`clearLandingMark`) or on shell dispose, and does not run during a -session. `still` freezes the mountain's draw/fill/fade timeline only; -snow still drifts on the idle landing. Reduced-motion, when a setting -exists, suppresses snow via `reducedMotion` on `renderMark` — not via -`still`. There is no live TUI setting yet. - -The mark is not driven from the renderer's FRAME event: FRAME follows -dirty rows, not a clock, so that path self-starves under throttle. -Keeping the monitor ticking on idle would mix turn-chrome cadence into a -pre-session surface; the monitor idle-stop stays correct for turn chrome. +While the landing is mounted, a mount-scoped 125ms timer advances snow +across a frozen mountain. It is cancelled on the first transcript row or +on shell dispose. While the landing is still up, the callback no-ops if +a turn is already driving the mark. `still` freezes the mountain's +draw/fill/fade timeline only; snow still drifts. Reduced motion drops +snow at `renderMark` independently of `still`; the idle timer always +paints with that hook off. + +That timer is the pre-session frame source. The renderer FRAME event +follows dirty rows, not a clock, and starves under throttle. The turn +monitor stays idle-stopped: mixing its cadence into the landing would +couple a pre-session surface to session activity. The model/provider picker is one flat, type-to-filter list (`src/tui/product-host.ts` + `openModelPickerOverlay({ typeToFilter: true })`): diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index 4e7284218..e7525ee48 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -50,11 +50,6 @@ const NOTICE = "Anonymous usage telemetry is enabled. Disable in /settings."; const nativeSetInterval = globalThis.setInterval; const nativeClearInterval = globalThis.clearInterval; -afterEach(() => { - globalThis.setInterval = nativeSetInterval; - globalThis.clearInterval = nativeClearInterval; -}); - /** * 125 is `LANDING_IDLE_REPAINT_INTERVAL_MS` in shell.ts. Hardcoded so a * cadence change fails these tests on purpose rather than tracking a product @@ -62,35 +57,42 @@ afterEach(() => { */ const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; -type IntervalHandle = ReturnType; +type IdleTimerHandle = { unref?: () => void }; -/** Wrap globals; the original handle is returned so `unref` still exists. */ function wrapLandingIdleTimer(): { - armed: IntervalHandle[]; - cleared: unknown[]; + armed: IdleTimerHandle[]; + cleared: IdleTimerHandle[]; } { - const armed: IntervalHandle[] = []; - const cleared: unknown[] = []; + const armed: IdleTimerHandle[] = []; + const cleared: IdleTimerHandle[] = []; + // Stub the landing cadence without a real timer — these tests only assert + // arm/clear. `unref` exists because the product calls it on the handle. globalThis.setInterval = (( handler: Parameters[0], delay?: number, ...args: unknown[] ) => { - const handle = nativeSetInterval.call(globalThis, handler, delay, ...args); - if (delay === LANDING_IDLE_REPAINT_INTERVAL_MS) armed.push(handle); - return handle; + if (delay === LANDING_IDLE_REPAINT_INTERVAL_MS) { + const handle: IdleTimerHandle = { unref() {} }; + armed.push(handle); + return handle; + } + return nativeSetInterval.call(globalThis, handler, delay, ...args); }) as typeof nativeSetInterval; globalThis.clearInterval = ((handle: Parameters[0]) => { - cleared.push(handle); + cleared.push(handle as IdleTimerHandle); + if (armed.includes(handle as IdleTimerHandle)) return; return nativeClearInterval.call(globalThis, handle); }) as typeof nativeClearInterval; return { armed, cleared }; } -function soleLandingIdleHandle(armed: readonly IntervalHandle[]): IntervalHandle { +function soleLandingIdleHandle(armed: readonly IdleTimerHandle[]): IdleTimerHandle { const handle = armed[0]; if (armed.length !== 1 || handle === undefined) { - throw new Error(`expected exactly one 125ms interval, got ${String(armed.length)}`); + throw new Error( + `expected exactly one ${String(LANDING_IDLE_REPAINT_INTERVAL_MS)}ms interval, got ${String(armed.length)}`, + ); } return handle; } @@ -355,38 +357,79 @@ describe("landing screen", () => { }, SIZE); }, 15_000); - test("appending a transcript row clears the landing idle timer", async () => { - const { armed, cleared } = wrapLandingIdleTimer(); + test("paintLanding with reducedMotion draws no snow on a clock that otherwise snows", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { - run: "idle", - wireKeys: false, terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", }); try { - const handle = soleLandingIdleHandle(armed); - appendStreamRow(shell, { role: "user", text: "first prompt" }); - expect(isLanding(shell)).toBe(false); - expect(cleared).toContain(handle); await settle(h); + const clocks = [0, 1500, 3000, 4500, 6000, 7500]; + let snowingAt: number | undefined; + for (const nowMs of clocks) { + paintLanding(shell, nowMs, false, false); + await settle(h); + if (markRows(h).some((row) => row.includes(SNOW_CHAR))) { + snowingAt = nowMs; + break; + } + } + if (snowingAt === undefined) { + throw new Error("expected a still-mode clock that draws snow through paintLanding"); + } + paintLanding(shell, snowingAt, false, true); + await settle(h); + expect(markRows(h).some((row) => row.includes(SNOW_CHAR))).toBe(false); } finally { shell.dispose(); } }, SIZE); }); - test("disposing the shell with no transcript clears the landing idle timer", async () => { - const { armed, cleared } = wrapLandingIdleTimer(); - await withTestRenderer(async (h) => { - const shell = createAppShell(h.renderer, { - run: "idle", - wireKeys: false, - terminal: { columns: 80, rows: 24 }, - }); - const handle = soleLandingIdleHandle(armed); - shell.dispose(); - expect(cleared).toContain(handle); - }, SIZE); + describe("landing idle timer", () => { + afterEach(() => { + globalThis.setInterval = nativeSetInterval; + globalThis.clearInterval = nativeClearInterval; + }); + + test("appending a transcript row clears the landing idle timer", async () => { + const { armed, cleared } = wrapLandingIdleTimer(); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + run: "idle", + wireKeys: false, + terminal: { columns: 80, rows: 24 }, + }); + try { + const handle = soleLandingIdleHandle(armed); + appendStreamRow(shell, { role: "user", text: "first prompt" }); + expect(isLanding(shell)).toBe(false); + expect(cleared).toContain(handle); + } finally { + shell.dispose(); + } + }, SIZE); + }); + + test("disposing the shell with no transcript clears the landing idle timer", async () => { + const { armed, cleared } = wrapLandingIdleTimer(); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + run: "idle", + wireKeys: false, + terminal: { columns: 80, rows: 24 }, + }); + try { + const handle = soleLandingIdleHandle(armed); + shell.dispose(); + expect(cleared).toContain(handle); + } finally { + if (isLanding(shell)) shell.dispose(); + } + }, SIZE); + }); }); test("a starter key fills the prompt; a typed prompt keeps its digits", async () => { diff --git a/src/tui/mark-anim.test.ts b/src/tui/mark-anim.test.ts index 5847eb899..47dcab766 100644 --- a/src/tui/mark-anim.test.ts +++ b/src/tui/mark-anim.test.ts @@ -15,6 +15,8 @@ const MOUNTAIN_CHARS = "▁▂▃▄▅▆▇█"; const isMountain = (char: string): boolean => MOUNTAIN_CHARS.includes(char); const isSnow = (char: string): boolean => char === SNOW_CHAR; +const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " "); +const SNOW_SAMPLE_CLOCKS_MS = [0, 1500, 3000, 4500, 6000, 7500] as const; describe("smooth", () => { test("clamps outside [0, 1] and eases inside it", () => { @@ -111,7 +113,6 @@ describe("renderMark", () => { test("still holds the mountain fixed while the clock advances", () => { // Snow moves with the clock even in still mode (the idle landing screen), // so isolate the mountain by stripping snow before comparing. - const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " "); const a = stripSnow(markText(renderMark({ nowMs: 0, still: true }))); const b = stripSnow(markText(renderMark({ nowMs: 987_654, still: true }))); expect(b).toBe(a); @@ -119,8 +120,7 @@ describe("renderMark", () => { }); test("snow keeps drifting in still mode while the mountain stays frozen", () => { - const times = [0, 1500, 3000, 4500, 6000, 7500]; - const snowSets = times.map((nowMs) => { + const snowSets = SNOW_SAMPLE_CLOCKS_MS.map((nowMs) => { const grid = renderMark({ nowMs, still: true, grid: MARK_LARGE }); const snow: string[] = []; grid.forEach((row, y) => { @@ -136,8 +136,7 @@ describe("renderMark", () => { }); test("reducedMotion drops snow at a clock that otherwise snows, without reshaping the mountain", () => { - const times = [0, 1500, 3000, 4500, 6000, 7500]; - const nowMs = times.find((t) => + const nowMs = SNOW_SAMPLE_CLOCKS_MS.find((t) => renderMark({ nowMs: t, still: true, reducedMotion: false, grid: MARK_LARGE }) .flat() .some((cell) => isSnow(cell.char)), @@ -150,12 +149,27 @@ describe("renderMark", () => { const quiet = renderMark({ nowMs, still: true, reducedMotion: true, grid: MARK_LARGE }); expect(snowing.flat().some((cell) => isSnow(cell.char))).toBe(true); expect(quiet.flat().some((cell) => isSnow(cell.char))).toBe(false); + expect(stripSnow(markText(quiet))).toBe(stripSnow(markText(snowing))); + }); - const mountainOnly = (grid: typeof snowing) => - grid - .map((row) => row.map((cell) => (isMountain(cell.char) ? cell.char : " ")).join("")) - .join("\n"); - expect(mountainOnly(quiet)).toBe(mountainOnly(snowing)); + test("reducedMotion drops snow at a hold-full clock without reshaping the mountain", () => { + const holdFullClocks = [0.76, 0.8, 0.85, 0.89].map( + (phase) => phase * MARK_PERIOD_SECONDS * 1000, + ); + const nowMs = holdFullClocks.find((t) => + renderMark({ nowMs: t, still: false, reducedMotion: false, grid: MARK_LARGE }) + .flat() + .some((cell) => isSnow(cell.char)), + ); + if (nowMs === undefined) { + throw new Error("expected a hold-full clock that draws snow"); + } + + const snowing = renderMark({ nowMs, still: false, reducedMotion: false, grid: MARK_LARGE }); + const quiet = renderMark({ nowMs, still: false, reducedMotion: true, grid: MARK_LARGE }); + expect(snowing.flat().some((cell) => isSnow(cell.char))).toBe(true); + expect(quiet.flat().some((cell) => isSnow(cell.char))).toBe(false); + expect(stripSnow(markText(quiet))).toBe(stripSnow(markText(snowing))); }); test("the animated frame advances with the injected clock", () => { @@ -199,8 +213,7 @@ describe("renderMark", () => { test("snow drifts over time without overwriting the silhouette", () => { // Sample across several seconds so flakes advance even at a slow fall rate. - const times = [0, 1500, 3000, 4500, 6000, 7500]; - const snowSets = times.map((nowMs) => { + const snowSets = SNOW_SAMPLE_CLOCKS_MS.map((nowMs) => { const grid = renderMark({ nowMs, still: false, grid: MARK_LARGE }); const snow: string[] = []; grid.forEach((row, y) => { diff --git a/src/tui/mark-anim.ts b/src/tui/mark-anim.ts index 5532557c3..9c37a1e2e 100644 --- a/src/tui/mark-anim.ts +++ b/src/tui/mark-anim.ts @@ -16,8 +16,7 @@ * that does suppress snow. Mountain cells always win over flakes. * * Everything here is pure and clock-injected: `nowMs` is the only time source, - * so tests drive it deterministically. The idle caller is the landing timer; - * the turn caller is the monitor. There is no timer in this module. + * so tests drive it deterministically. There is no timer in this module. */ import { MARK_SMALL, type MarkGrid } from "./mark-shape.js"; @@ -51,7 +50,8 @@ export interface MarkFrame { /** * The looping timeline: draw in (0-38%), hold (38-48%), fill bottom-up * (48-76%), hold full (76-90%), fade out (90-100%), then repeat. `still` - * (reduced motion, or an idle session) is a static, fully-filled mark. + * freezes that timeline on a fully-filled mark (idle landing). Reduced + * motion is a separate snow gate. */ export function markFrame(seconds: number, still: boolean): MarkFrame { if (still) return { drawProg: 1, fillProg: 1, alpha: 1 }; @@ -97,17 +97,12 @@ export interface MarkCell { export interface MarkInput { readonly nowMs: number; /** - * Hold the mountain's draw/fill/fade timeline on its fully-filled frame: - * idle session, or reduced motion. Snow is not gated by this — see - * `snowOn` in `renderMark`. + * Hold the mountain's draw/fill/fade timeline on its fully-filled frame + * (idle landing). Snow is not gated by this — see `reducedMotion`. */ readonly still: boolean; /** - * Reduced-motion hook: suppresses snow regardless of `still`. Nothing - * wires a live setting into this yet, but the parameter exists so a - * future reduced-motion setting has a real path to gate motion, rather - * than overloading `still` (which only ever freezes the mountain's - * draw/fill/fade timeline). Defaults to off. + * Suppresses snow regardless of `still`. Defaults to off. */ readonly reducedMotion?: boolean; /** Which baked rasterization to composite. Defaults to the compact grid. */ diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 6ab623251..4473df7e6 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -2110,10 +2110,10 @@ interface ShellInternals { /** Clock of the last painted mark frame, so a resize can redraw in place. */ landingNowMs: number; /** - * Cancels the mount-scoped idle repaint timer (see `armLandingIdleTimer` - * in `createAppShell`), or null while none is armed. Cleared by whichever - * teardown happens first — the landing going away (`clearLandingMark`) or - * the whole shell disposing (`dispose`) — so it can never outlive either. + * Cancels the mount-scoped idle repaint timer armed in `createAppShell`, + * or null while none is armed. Cleared by whichever teardown happens + * first — the landing going away (`clearLandingMark`) or the whole shell + * disposing (`dispose`) — so it can never outlive either. */ landingIdleTimerCancel: (() => void) | null; /** Chrome content (empty array = zone off). */ @@ -2797,8 +2797,8 @@ function clearLandingMark(shell: AppShell): void { } /** - * Cadence of the mount-scoped idle repaint timer (see `armLandingIdleTimer` - * in `createAppShell`). The snow only needs to advance about half a row per + * 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. */ const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; @@ -2811,9 +2811,8 @@ const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; * Always repaints while the landing is up, even when `animating` is false: * the landing is idle by definition (no turn processing), and snow still * needs to drift across a frozen mountain. Driven by the mount-scoped timer - * armed in `createAppShell` (see `armLandingIdleTimer`) rather than a render - * event, so the repaint cadence is independent of however often the renderer - * happens to paint. + * armed in `createAppShell` rather than a render event, so the repaint + * cadence is independent of however often the renderer happens to paint. */ export function paintLanding( shell: AppShell, From bd2b13bd817205e0ef6b5a7db96426b69a1f9d92 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 12:29:21 -0700 Subject: [PATCH 4/8] Stop the idle timer from racing reduced-motion paints --- docs/TUI.md | 13 +++---- src/tui/landing.test.ts | 78 ++++++++++++++++++++++------------------- src/tui/shell.ts | 6 ++-- 3 files changed, 52 insertions(+), 45 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 6d04f78c2..3aab6beba 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -430,12 +430,13 @@ known, accepted cost of the badge rather than an oversight — see `terminalForGeometry`'s doc comment in `shell.ts` for the exact mechanism. While the landing is mounted, a mount-scoped 125ms timer advances snow -across a frozen mountain. It is cancelled on the first transcript row or -on shell dispose. While the landing is still up, the callback no-ops if -a turn is already driving the mark. `still` freezes the mountain's -draw/fill/fade timeline only; snow still drifts. Reduced motion drops -snow at `renderMark` independently of `still`; the idle timer always -paints with that hook off. +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 +driving the mark. `still` freezes the mountain's draw/fill/fade +timeline only; snow still drifts. Reduced motion drops snow at +`renderMark` independently of `still`; the idle timer always paints +with that hook off. That timer is the pre-session frame source. The renderer FRAME event follows dirty rows, not a clock, and starves under throttle. The turn diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index e7525ee48..348478325 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -56,6 +56,7 @@ const nativeClearInterval = globalThis.clearInterval; * export. */ const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; +const SNOW_SAMPLE_CLOCKS_MS = [0, 1500, 3000, 4500, 6000, 7500] as const; type IdleTimerHandle = { unref?: () => void }; @@ -65,15 +66,14 @@ function wrapLandingIdleTimer(): { } { const armed: IdleTimerHandle[] = []; const cleared: IdleTimerHandle[] = []; - // Stub the landing cadence without a real timer — these tests only assert - // arm/clear. `unref` exists because the product calls it on the handle. + // Do not arm a real interval: these tests assert arm/clear, not ticks. globalThis.setInterval = (( handler: Parameters[0], delay?: number, ...args: unknown[] ) => { if (delay === LANDING_IDLE_REPAINT_INTERVAL_MS) { - const handle: IdleTimerHandle = { unref() {} }; + const handle: IdleTimerHandle = {}; armed.push(handle); return handle; } @@ -91,7 +91,7 @@ function soleLandingIdleHandle(armed: readonly IdleTimerHandle[]): IdleTimerHand const handle = armed[0]; if (armed.length !== 1 || handle === undefined) { throw new Error( - `expected exactly one ${String(LANDING_IDLE_REPAINT_INTERVAL_MS)}ms interval, got ${String(armed.length)}`, + `expected exactly one ${LANDING_IDLE_REPAINT_INTERVAL_MS}ms interval, got ${armed.length}`, ); } return handle; @@ -357,43 +357,49 @@ describe("landing screen", () => { }, SIZE); }, 15_000); - test("paintLanding with reducedMotion draws no snow on a clock that otherwise snows", async () => { - await withTestRenderer(async (h) => { - const shell = createAppShell(h.renderer, { - terminal: { columns: 80, rows: 24 }, - wireKeys: false, - run: "idle", - }); - try { - await settle(h); - const clocks = [0, 1500, 3000, 4500, 6000, 7500]; - let snowingAt: number | undefined; - for (const nowMs of clocks) { - paintLanding(shell, nowMs, false, false); - await settle(h); - if (markRows(h).some((row) => row.includes(SNOW_CHAR))) { - snowingAt = nowMs; - break; - } - } - if (snowingAt === undefined) { - throw new Error("expected a still-mode clock that draws snow through paintLanding"); - } - paintLanding(shell, snowingAt, false, true); - await settle(h); - expect(markRows(h).some((row) => row.includes(SNOW_CHAR))).toBe(false); - } finally { - shell.dispose(); - } - }, SIZE); - }); - describe("landing idle timer", () => { afterEach(() => { globalThis.setInterval = nativeSetInterval; globalThis.clearInterval = nativeClearInterval; }); + test("paintLanding with reducedMotion draws no snow on a clock that otherwise snows", async () => { + wrapLandingIdleTimer(); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + try { + await settle(h); + const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " "); + let snowingAt: number | undefined; + let snowing = ""; + for (const nowMs of SNOW_SAMPLE_CLOCKS_MS) { + paintLanding(shell, nowMs, false, false); + await settle(h); + const frame = markRows(h).join("\n"); + if (frame.includes(SNOW_CHAR)) { + snowingAt = nowMs; + snowing = frame; + break; + } + } + if (snowingAt === undefined) { + throw new Error("expected a still-mode clock that draws snow through paintLanding"); + } + paintLanding(shell, snowingAt, false, true); + await settle(h); + const quiet = markRows(h).join("\n"); + expect(quiet.includes(SNOW_CHAR)).toBe(false); + expect(stripSnow(quiet)).toBe(stripSnow(snowing)); + } finally { + shell.dispose(); + } + }, SIZE); + }); + test("appending a transcript row clears the landing idle timer", async () => { const { armed, cleared } = wrapLandingIdleTimer(); await withTestRenderer(async (h) => { @@ -426,7 +432,7 @@ describe("landing screen", () => { shell.dispose(); expect(cleared).toContain(handle); } finally { - if (isLanding(shell)) shell.dispose(); + shell.dispose(); } }, SIZE); }); diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 4473df7e6..8789a1a0a 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -2798,8 +2798,8 @@ 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 only needs to advance about half a row per second, so 8fps is + * comfortably enough to read as motion. */ const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; @@ -6663,7 +6663,7 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption // turn monitor is deliberately quiet (idle, no session yet). A plain timer // armed at mount is that source: it does not depend on the renderer // scheduling further frames, so it cannot stall the way riding the - // renderer's own FRAME event did (see CL-5737 history in the PR). + // renderer's FRAME event does: FRAME follows dirty rows, not a clock. // // Only repaints while idle (`landingAnimating` false): while a turn is // processing, `paintPhaseAt` in runtime-bridge.ts drives the mountain's From 96403d16b40288bec4f3b313b736ca5acd495d6d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 12:38:21 -0700 Subject: [PATCH 5/8] Pin deferred notices leave the landing timer armed --- src/tui/landing.test.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index 348478325..3ae2c333b 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -57,6 +57,7 @@ const nativeClearInterval = globalThis.clearInterval; */ const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; const SNOW_SAMPLE_CLOCKS_MS = [0, 1500, 3000, 4500, 6000, 7500] as const; +const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " "); type IdleTimerHandle = { unref?: () => void }; @@ -66,7 +67,7 @@ function wrapLandingIdleTimer(): { } { const armed: IdleTimerHandle[] = []; const cleared: IdleTimerHandle[] = []; - // Do not arm a real interval: these tests assert arm/clear, not ticks. + // Do not arm a real interval. Callers inject clocks or only inspect handles. globalThis.setInterval = (( handler: Parameters[0], delay?: number, @@ -286,7 +287,6 @@ describe("landing screen", () => { try { await settle(h); const still = markRows(h).join("\n"); - const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " "); // Idle re-entry holds the mountain's filled frame however far the // clock moves — but the snow drifting over it is not still, since the @@ -348,8 +348,6 @@ describe("landing screen", () => { } expect(after).not.toBe(before); - - const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " "); expect(stripSnow(after)).toBe(stripSnow(before)); } finally { shell.dispose(); @@ -373,7 +371,6 @@ describe("landing screen", () => { }); try { await settle(h); - const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " "); let snowingAt: number | undefined; let snowing = ""; for (const nowMs of SNOW_SAMPLE_CLOCKS_MS) { @@ -400,6 +397,28 @@ describe("landing screen", () => { }, SIZE); }); + test("a deferred system notice does not clear the landing idle timer", async () => { + const { armed, cleared } = wrapLandingIdleTimer(); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + run: "idle", + wireKeys: false, + terminal: { columns: 80, rows: 24 }, + }); + try { + const handle = soleLandingIdleHandle(armed); + surfaceSystemNotice( + shell, + "mcp github did not connect (ECONNREFUSED) — its tools are unavailable; /mcp for detail", + ); + expect(isLanding(shell)).toBe(true); + expect(cleared).not.toContain(handle); + } finally { + shell.dispose(); + } + }, SIZE); + }); + test("appending a transcript row clears the landing idle timer", async () => { const { armed, cleared } = wrapLandingIdleTimer(); await withTestRenderer(async (h) => { From 8ed2ddcc5d1290413302398cf64ef209392c461d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 22:15:37 -0700 Subject: [PATCH 6/8] Use an interface for the landing test double --- src/tui/landing.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index 3ae2c333b..dc52a23a4 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -59,7 +59,9 @@ const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; const SNOW_SAMPLE_CLOCKS_MS = [0, 1500, 3000, 4500, 6000, 7500] as const; const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " "); -type IdleTimerHandle = { unref?: () => void }; +interface IdleTimerHandle { + unref?: () => void; +} function wrapLandingIdleTimer(): { armed: IdleTimerHandle[]; From 0f67623512ba97458ed27e6bb89d2be7511d243b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 22:16:41 -0700 Subject: [PATCH 7/8] Honor reduced motion on the landing mount path --- docs/TUI.md | 7 +++-- src/tui/landing.test.ts | 34 +++++++++----------- src/tui/landing.ts | 4 +-- src/tui/product-host.ts | 6 ++++ src/tui/shell.ts | 70 ++++++++++++++++++++++++++--------------- 5 files changed, 70 insertions(+), 51 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 3aab6beba..adf3dab88 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -434,9 +434,10 @@ 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 driving the mark. `still` freezes the mountain's draw/fill/fade -timeline only; snow still drifts. Reduced motion drops snow at -`renderMark` independently of `still`; the idle timer always paints -with that hook off. +timeline only; snow still drifts. Reduced motion +(`AppShellOptions.reducedMotion`, forwarded from `ProductHostConfig`) +never starts that timer and paints a still mountain with no snow, even +when a caller asks `paintLanding` to animate. That timer is the pre-session frame source. The renderer FRAME event follows dirty rows, not a clock, and starves under throttle. The turn diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index dc52a23a4..a883ded6a 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -56,7 +56,6 @@ const nativeClearInterval = globalThis.clearInterval; * export. */ const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; -const SNOW_SAMPLE_CLOCKS_MS = [0, 1500, 3000, 4500, 6000, 7500] as const; const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " "); interface IdleTimerHandle { @@ -363,36 +362,31 @@ describe("landing screen", () => { globalThis.clearInterval = nativeClearInterval; }); - test("paintLanding with reducedMotion draws no snow on a clock that otherwise snows", async () => { - wrapLandingIdleTimer(); + test("reduced-motion mount never arms the idle timer and never draws snow", async () => { + const { armed } = wrapLandingIdleTimer(); await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { terminal: { columns: 80, rows: 24 }, wireKeys: false, run: "idle", + reducedMotion: true, }); try { + expect(armed).toHaveLength(0); await settle(h); - let snowingAt: number | undefined; - let snowing = ""; - for (const nowMs of SNOW_SAMPLE_CLOCKS_MS) { - paintLanding(shell, nowMs, false, false); + const first = markRows(h).join("\n"); + expect(first.includes(SNOW_CHAR)).toBe(false); + expect(first.length).toBeGreaterThan(0); + + const frames = new Set([first]); + for (const nowMs of [0, 500, 1_100, 1_900, 2_600, 3_400]) { + paintLanding(shell, nowMs, true); await settle(h); const frame = markRows(h).join("\n"); - if (frame.includes(SNOW_CHAR)) { - snowingAt = nowMs; - snowing = frame; - break; - } - } - if (snowingAt === undefined) { - throw new Error("expected a still-mode clock that draws snow through paintLanding"); + expect(frame.includes(SNOW_CHAR)).toBe(false); + frames.add(frame); } - paintLanding(shell, snowingAt, false, true); - await settle(h); - const quiet = markRows(h).join("\n"); - expect(quiet.includes(SNOW_CHAR)).toBe(false); - expect(stripSnow(quiet)).toBe(stripSnow(snowing)); + expect(frames.size).toBe(1); } finally { shell.dispose(); } diff --git a/src/tui/landing.ts b/src/tui/landing.ts index 517774109..9449c238c 100644 --- a/src/tui/landing.ts +++ b/src/tui/landing.ts @@ -290,7 +290,7 @@ export interface LandingAbove { * Rows are allocated for the largest tier once and hidden from the top down as * smaller tiers are selected, so a resize never rebuilds the subtree. */ -export function createLandingAbove(ctx: CliRenderer): LandingAbove { +export function createLandingAbove(ctx: CliRenderer, reducedMotion = false): LandingAbove { const box = new BoxRenderable(ctx, { id: "shell-landing-above", width: "100%", @@ -346,7 +346,7 @@ export function createLandingAbove(ctx: CliRenderer): LandingAbove { grid: MARK_SMALL, }; fitLandingMark(above, MARK_SMALL); - paintLandingMark(above, 0, true); + paintLandingMark(above, 0, true, reducedMotion); return above; } diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 71d09cabd..3542295c5 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -175,6 +175,11 @@ export interface ProductHostConfig { readonly turnMonitor?: TurnMonitorOptions; /** First-run telemetry disclosure, shown on the landing screen. */ readonly telemetryNotice?: string; + /** + * Suppress landing snow and mountain motion. Forwarded to the shell at + * mount; the idle timer is never armed. + */ + readonly reducedMotion?: boolean; /** * Take DEC mouse reporting. Default true: wheel/trackpad scroll only * reaches OpenTUI when the terminal is told to report it, otherwise the @@ -311,6 +316,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise 0; } @@ -2109,6 +2114,12 @@ interface ShellInternals { landingAnimating: boolean; /** Clock of the last painted mark frame, so a resize can redraw in place. */ landingNowMs: number; + /** + * Mount-time reduced-motion flag. When true, the idle snow timer is + * never armed and every landing paint holds a still mountain with no + * flakes. Set once at `createAppShell`; not a per-paint argument. + */ + reducedMotion: boolean; /** * Cancels the mount-scoped idle repaint timer armed in `createAppShell`, * or null while none is armed. Cleared by whichever teardown happens @@ -2813,19 +2824,19 @@ const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; * needs to drift across a frozen mountain. Driven by the mount-scoped timer * armed in `createAppShell` rather than a render event, so the repaint * cadence is independent of however often the renderer happens to paint. + * + * Reduced motion is a mount-time flag on the shell, not a per-paint + * argument: it freezes the mountain and drops snow even when a caller + * asks for `animating`. */ -export function paintLanding( - shell: AppShell, - nowMs: number, - animating: boolean, - reducedMotion = false, -): void { +export function paintLanding(shell: AppShell, nowMs: number, animating: boolean): void { const bag = internals.get(shell); const landing = bag?.landing; if (bag === undefined || landing === null || landing === undefined) return; - bag.landingAnimating = animating; + const motion = bag.reducedMotion ? false : animating; + bag.landingAnimating = motion; bag.landingNowMs = nowMs; - paintLandingMark(landing.above, nowMs, !animating, reducedMotion); + paintLandingMark(landing.above, nowMs, !motion, bag.reducedMotion); } /** True while the landing composition is still mounted. */ @@ -5749,6 +5760,7 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption const paletteCatalogOpt = options?.paletteCatalog ?? null; const onCommandOpt = options?.onCommand; const onObserveRequestOpt = options?.onObserveRequest; + const reducedMotion = options?.reducedMotion === true; const terminal = terminalOf(renderer, options?.terminal); const layout = resolveGeometry({ @@ -5865,7 +5877,7 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption }); transcript.add(transcriptSpacer); - const landingAbove = createLandingAbove(ctx); + const landingAbove = createLandingAbove(ctx, reducedMotion); const landingBelowState = landingBelowContent({ rows: splitLandingRows(layout.heights.transcript).below, columns: layout.contentWidth, @@ -6650,6 +6662,7 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption landingSuggestionsVisible: true, landingAnimating: false, landingNowMs: 0, + reducedMotion, landingIdleTimerCancel: null, chrome: { task: [], tasksRaw: [], agents: [] }, // CL-5847: the manage_tasks checklist panel is hidden by default. The @@ -6679,22 +6692,27 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption // the renderer directly (`withTestRenderer`'s cleanup) without ever // calling `shell.dispose()`. Without this check the timer would keep // firing against renderables the harness already tore down. - const landingIdleHandle = setInterval(() => { - if (renderer.isDestroyed) { - clearInterval(landingIdleHandle); - return; - } - const bag = internals.get(shell); - if (bag?.landing == null || bag.landingAnimating) return; - paintLanding(shell, Date.now(), false); - }, LANDING_IDLE_REPAINT_INTERVAL_MS); - landingIdleHandle.unref?.(); - { - const bag = internals.get(shell); - if (bag !== undefined) { - bag.landingIdleTimerCancel = () => clearInterval(landingIdleHandle); - } else { - clearInterval(landingIdleHandle); + // + // Reduced motion never starts the timer: there is no snow to advance + // and the mountain stays on its filled frame. + if (!reducedMotion) { + const landingIdleHandle = setInterval(() => { + if (renderer.isDestroyed) { + clearInterval(landingIdleHandle); + return; + } + const bag = internals.get(shell); + if (bag?.landing == null || bag.landingAnimating) return; + paintLanding(shell, Date.now(), false); + }, LANDING_IDLE_REPAINT_INTERVAL_MS); + landingIdleHandle.unref?.(); + { + const bag = internals.get(shell); + if (bag !== undefined) { + bag.landingIdleTimerCancel = () => clearInterval(landingIdleHandle); + } else { + clearInterval(landingIdleHandle); + } } } transcriptSpacers.set(shell, transcriptSpacer); From b2e711c55d80e14cbb02fc9471dadefb5215c6ac Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 22:44:30 -0700 Subject: [PATCH 8/8] Share the landing idle interval with its test helper A hardcoded delay would still report no armed timer if the product cadence moved while reduced-motion skip stayed broken. --- src/tui/landing.test.ts | 11 +++++------ src/tui/shell.ts | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index a883ded6a..a548f6890 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -11,6 +11,7 @@ import { appendStreamRow, applyLandingSuggestion, createAppShell, + LANDING_IDLE_REPAINT_INTERVAL_MS, noticeText, paintChrome, setChromeZones, @@ -50,18 +51,16 @@ const NOTICE = "Anonymous usage telemetry is enabled. Disable in /settings."; const nativeSetInterval = globalThis.setInterval; const nativeClearInterval = globalThis.clearInterval; -/** - * 125 is `LANDING_IDLE_REPAINT_INTERVAL_MS` in shell.ts. Hardcoded so a - * cadence change fails these tests on purpose rather than tracking a product - * export. - */ -const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " "); interface IdleTimerHandle { unref?: () => void; } +/** + * Intercepts the product idle interval so a cadence change cannot hide a + * still-armed timer from the reduced-motion assertion. + */ function wrapLandingIdleTimer(): { armed: IdleTimerHandle[]; cleared: IdleTimerHandle[]; diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 319eb1c7f..fe6eefb37 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -2812,7 +2812,7 @@ function clearLandingMark(shell: AppShell): void { * The snow only needs to advance about half a row per second, so 8fps is * comfortably enough to read as motion. */ -const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; +export const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; /** * Repaint the landing mark for `nowMs`. `animating` runs the mountain's