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
21 changes: 19 additions & 2 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
153 changes: 149 additions & 4 deletions src/tui/landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
* 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";
import {
appendStreamRow,
applyLandingSuggestion,
createAppShell,
LANDING_IDLE_REPAINT_INTERVAL_MS,
noticeText,
paintChrome,
setChromeZones,
Expand Down Expand Up @@ -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<typeof nativeSetInterval>[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<typeof nativeClearInterval>[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<void> {
await h.renderOnce();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -298,15 +348,110 @@ 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();
}
}, 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<string>([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, {
Expand Down
4 changes: 2 additions & 2 deletions src/tui/landing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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%",
Expand Down Expand Up @@ -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;
}

Expand Down
46 changes: 41 additions & 5 deletions src/tui/mark-anim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -111,16 +113,14 @@ 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);
expect(a.replace(/[\s\n]/g, "").length).toBeGreaterThan(0);
});

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) => {
Expand All @@ -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 })),
Expand Down Expand Up @@ -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) => {
Expand Down
17 changes: 6 additions & 11 deletions src/tui/mark-anim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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. */
Expand Down
6 changes: 6 additions & 0 deletions src/tui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -311,6 +316,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
...(config.onCommand !== undefined ? { onCommand: config.onCommand } : {}),
...(config.onObserveRequest !== undefined ? { onObserveRequest: config.onObserveRequest } : {}),
...(config.telemetryNotice !== undefined ? { telemetryNotice: config.telemetryNotice } : {}),
...(config.reducedMotion === true ? { reducedMotion: true } : {}),
});

// Announced on the notice strip (or transcript once the session has content)
Expand Down
Loading
Loading