diff --git a/docs/TUI.md b/docs/TUI.md index 8a8dd3d12..adf3dab88 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,21 @@ 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 125ms 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 +driving the mark. `still` freezes the mountain's draw/fill/fade +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 +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 })`): recent and favorite provider+model pairs sit at the top, then every diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index f8c3d7c19..a548f6890 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"; @@ -11,6 +11,7 @@ import { appendStreamRow, applyLandingSuggestion, createAppShell, + LANDING_IDLE_REPAINT_INTERVAL_MS, noticeText, paintChrome, setChromeZones, @@ -47,6 +48,56 @@ 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; + +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[]; +} { + const armed: IdleTimerHandle[] = []; + const cleared: IdleTimerHandle[] = []; + // Do not arm a real interval. Callers inject clocks or only inspect handles. + globalThis.setInterval = (( + handler: Parameters[0], + delay?: number, + ...args: unknown[] + ) => { + if (delay === LANDING_IDLE_REPAINT_INTERVAL_MS) { + const handle: IdleTimerHandle = {}; + armed.push(handle); + return handle; + } + return nativeSetInterval.call(globalThis, handler, delay, ...args); + }) as typeof nativeSetInterval; + globalThis.clearInterval = ((handle: Parameters[0]) => { + 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 IdleTimerHandle[]): IdleTimerHandle { + const handle = armed[0]; + if (armed.length !== 1 || handle === undefined) { + throw new Error( + `expected exactly one ${LANDING_IDLE_REPAINT_INTERVAL_MS}ms interval, got ${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(); @@ -236,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 @@ -298,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(); @@ -307,6 +355,103 @@ describe("landing screen", () => { }, SIZE); }, 15_000); + describe("landing idle timer", () => { + afterEach(() => { + globalThis.setInterval = nativeSetInterval; + globalThis.clearInterval = nativeClearInterval; + }); + + 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); + 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"); + expect(frame.includes(SNOW_CHAR)).toBe(false); + frames.add(frame); + } + expect(frames.size).toBe(1); + } finally { + shell.dispose(); + } + }, 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) => { + 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 { + shell.dispose(); + } + }, 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/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/mark-anim.test.ts b/src/tui/mark-anim.test.ts index 6015d458f..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) => { @@ -135,6 +135,43 @@ describe("renderMark", () => { expect(new Set(withSnow).size).toBeGreaterThan(1); }); + test("reducedMotion drops snow at a clock that otherwise snows, without reshaping the mountain", () => { + const nowMs = SNOW_SAMPLE_CLOCKS_MS.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); + expect(stripSnow(markText(quiet))).toBe(stripSnow(markText(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", () => { const frames = [0, 400, 900, 1500, 2400, 3200].map((nowMs) => markText(renderMark({ nowMs, still: false })), @@ -176,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 16d0ecfe4..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 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. 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/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; } @@ -2110,10 +2115,16 @@ 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. + * 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 + * 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,11 +2808,11 @@ 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 - * second, so ~8fps is comfortably enough to read as motion. + * 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; +export const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; /** * Repaint the landing mark for `nowMs`. `animating` runs the mountain's @@ -2811,22 +2822,21 @@ 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. + * + * 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. */ @@ -5750,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({ @@ -5866,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, @@ -6651,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 @@ -6664,7 +6676,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 @@ -6680,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);