Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 126 additions & 1 deletion src/tui/landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,6 +32,7 @@ import {
LANDING_HINTS,
LANDING_SUGGESTIONS,
LANDING_VERSION,
fitLandingMark,
landingBelowContent,
landingBelowRows,
landingSuggestionFor,
Expand Down Expand Up @@ -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<typeof nativeSetInterval>[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<typeof nativeClearInterval>[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<void> {
await h.renderOnce();
Expand Down Expand Up @@ -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 () => {
Expand Down
6 changes: 3 additions & 3 deletions src/tui/shell/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/tui/shell/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
Expand Down
Loading