# Tick Heart

> For agents: start with the [agent guide](https://pi-tui.ratstack.sh/llms.txt). Every page here has a Markdown twin, and every pattern has a JSON record.

Behavioral / Animation · `tick-heart` · [HTML](https://pi-tui.ratstack.sh/patterns/tick-heart/) · [JSON](https://pi-tui.ratstack.sh/patterns/tick-heart.json) · [all patterns](https://pi-tui.ratstack.sh/patterns.md)

Also known as Fixed-tick view.

## Intent

Advance local view state and request redraws on a component-owned interval.

## Motivation

Nico's arcade components own intervals that advance local state independently of keyboard input.

## Applicability

- Use this when animation should continue independently of user input.

## Structure

```text
interval -> local state -> redraw
input -> local state
dispose -> stop interval
```

## Participants

- [`ctx.ui.custom`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays): Mounts one interaction and resolves through done.
- [`Component`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model): Renders width-bounded lines and invalidates cached output.
- [`TUI.requestRender`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model): Requests a coalesced redraw after state changes.
- `Owned interval`: Advances local state until disposal stops it.

Pi screen APIs: [`ctx.ui.custom`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays), [`Component`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), [`TUI.requestRender`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), `Box`

## Consequences

- A live view can animate without a model or user event.
- The interval must stop on disposal and resizing can affect behavior.

## Implementation

- Clear the interval on disposal.
- Keep resize-dependent behavior explicit.
- The story demonstrates reusable ticking rather than copying a game.

## States

Each state was rendered at 40, 60, 80 and 120 columns in Pi's dark and light themes. Each frame links one WebP. An animated frame links its first image, then the animation.

### Ticking

`running`: Animate a synthetic progress marker at a fixed cadence.

Checks at every width and theme:

- width: ✓ pass
- style-leak: ✓ pass
- hard-coded-colour: ✓ pass
- height: ✓ pass

Frames:

- 40 columns: ![Tick Heart, Ticking, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/running-40-dark.a94e35dcc0f0.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/running-40-dark.anim.5581822b8dbc.webp) ![Tick Heart, Ticking, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/running-40-light.7fdfd1b3ba05.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/running-40-light.anim.04f03cd43b07.webp)
- 60 columns: ![Tick Heart, Ticking, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/running-60-dark.7146ec8d42c0.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/running-60-dark.anim.7eef561e0cfd.webp) ![Tick Heart, Ticking, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/running-60-light.0e088a4d7c30.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/running-60-light.anim.6dbe3bf962e7.webp)
- 80 columns: ![Tick Heart, Ticking, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/running-80-dark.df31f909feb5.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/running-80-dark.anim.b86214fea2ac.webp) ![Tick Heart, Ticking, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/running-80-light.e268405491c5.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/running-80-light.anim.1086a6c17742.webp)
- 120 columns: ![Tick Heart, Ticking, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/running-120-dark.1592c353d864.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/running-120-dark.anim.4e6aa8919895.webp) ![Tick Heart, Ticking, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/running-120-light.4ce92d893de8.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/running-120-light.anim.645090b7e8ea.webp)

### Narrow view

`narrow`: Resize and show the view's explicit compact or paused behavior.

Checks at every width and theme:

- width: ✓ pass
- style-leak: ✓ pass
- hard-coded-colour: ✓ pass
- height: ✓ pass

Frames:

- 40 columns: ![Tick Heart, Narrow view, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-40-dark.3f696bc90db4.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-40-dark.anim.dd36d5b13ea4.webp) ![Tick Heart, Narrow view, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-40-light.ea351ebf5bf6.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-40-light.anim.ac4312e748ce.webp)
- 60 columns: ![Tick Heart, Narrow view, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-60-dark.c1d6ca3cca09.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-60-dark.anim.f3fab449d90a.webp) ![Tick Heart, Narrow view, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-60-light.a8346b70eb1f.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-60-light.anim.b8d6a15b1c29.webp)
- 80 columns: ![Tick Heart, Narrow view, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-80-dark.67a7393ecc40.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-80-dark.anim.03e77df5bf77.webp) ![Tick Heart, Narrow view, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-80-light.65ebd94901fb.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-80-light.anim.1685713bc1bd.webp)
- 120 columns: ![Tick Heart, Narrow view, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-120-dark.cffa473ffec5.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-120-dark.anim.9fce12a6308c.webp) ![Tick Heart, Narrow view, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-120-light.ca7c35ce6ae5.webp) [animated](https://pi-tui.ratstack.sh/frames/tick-heart/narrow-120-light.anim.2e73cafb85f2.webp)

### Disposed

`disposed`: Close the interaction and stop further ticks.

Checks at every width and theme:

- width: ✓ pass
- style-leak: ✓ pass
- hard-coded-colour: ✓ pass
- height: ✓ pass

Frames:

- 40 columns: ![Tick Heart, Disposed, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/disposed-40-dark.38a48f43944e.webp) ![Tick Heart, Disposed, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/disposed-40-light.969013d0ad0a.webp)
- 60 columns: ![Tick Heart, Disposed, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/disposed-60-dark.a8ceaace8361.webp) ![Tick Heart, Disposed, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/disposed-60-light.a47f559a2536.webp)
- 80 columns: ![Tick Heart, Disposed, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/disposed-80-dark.23fc1b4a7560.webp) ![Tick Heart, Disposed, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/disposed-80-light.d5ae6040c589.webp)
- 120 columns: ![Tick Heart, Disposed, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/tick-heart/disposed-120-dark.11f5ca8736d6.webp) ![Tick Heart, Disposed, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/tick-heart/disposed-120-light.acb8ef1bd21c.webp)

## Sample code

`stories/patterns/behavioral/tick-heart.ts`, the story the frames above were rendered from.

```ts
// Tick Heart: a component-owned interval advances local state until disposal.
import { Box, Text, truncateToWidth } from "@earendil-works/pi-tui";
import type { PatternStory } from "../../../src/pattern.ts";

export const story: PatternStory = {
  id: "tick-heart", title: "Tick Heart", kind: "screen",
  apis: ["ctx.ui.custom", "Component", "Box", "TUI.requestRender"],
  states: [
    { id: "running", label: "Ticking", animated: true, steps: [{ type: "tick", ms: 600 }] },
    { id: "narrow", label: "Compact viewport", animated: true, steps: [
      { type: "resize", rows: 12 }, { type: "tick", ms: 400 },
    ] },
    { id: "disposed", label: "Disposed", steps: [
      { type: "keys", data: "\x1b" }, { type: "tick", ms: 400 },
      { type: "action", name: "prove-stopped" },
    ] },
  ],
  setup({ ui, theme, action }) {
    let ticks = 0, stoppedAt = 0;
    let lifecycle: "running" | "disposed" = "running";
    ui.setEditorText("Draft a progress note");
    ui.setWidget("heartbeat", [theme.fg("muted", "Local timer · no work is running")]);
    action("prove-stopped", () => {
      if (lifecycle !== "disposed" || ticks !== stoppedAt) throw new Error("Interval survived disposal");
      ui.setWidget("heartbeat", [
        theme.fg("success", "Disposed · stopped at tick " + stoppedAt),
        theme.fg("muted", "400ms later · no further ticks"),
      ]);
    });
    void ui.custom<void>((tui, theme, keys, done) => {
      const interval = setInterval(() => { ticks++; tui.requestRender(); }, 200);
      function dispose() {
        if (lifecycle === "disposed") return;
        lifecycle = "disposed";
        stoppedAt = ticks;
        clearInterval(interval);
      }
      return {
        dispose,
        invalidate() {},
        handleInput(data) {
          if (keys.matches(data, "tui.select.cancel")) { dispose(); done(); }
        },
        render(width) {
          // Shrinking height demonstrates compact mode without changing matrix columns.
          const compact = width < 60 || tui.terminal.rows <= 12;
          const slots = compact ? 8 : 16;
          const marker = Array.from({ length: slots }, (_, index) => index === ticks % slots ? "●" : "·").join(" ");
          const panel = new Box(1, 1, text => theme.bg("customMessageBg", text));
          panel.addChild(new Text(theme.fg("accent", "Heartbeat · tick " + ticks + (compact ? " · compact" : " · full")), 0, 0));
          panel.addChild(new Text(theme.fg("success", truncateToWidth(marker, width - 2)), 0, 0));
          panel.addChild(new Text(theme.fg("dim", "200ms cadence · escape closes"), 0, 0));
          return panel.render(width);
        },
      };
    });
  },
};
```

## Known uses: seen in Nico's repos

- [**pi-extensions**](https://github.com/nicobailon/pi-extensions): Arcade: fixed-tick full-screen component
  - [arcade/tetris.ts:187-233](https://github.com/nicobailon/pi-extensions/blob/bca5070b541ffa7d273e74036dcd7d5f8b63eed3/arcade/tetris.ts#L187-L233) @bca5070b
  - [arcade/ping.ts:86-139](https://github.com/nicobailon/pi-extensions/blob/bca5070b541ffa7d273e74036dcd7d5f8b63eed3/arcade/ping.ts#L86-L139) @bca5070b
  - [arcade/picman.ts:214-245](https://github.com/nicobailon/pi-extensions/blob/bca5070b541ffa7d273e74036dcd7d5f8b63eed3/arcade/picman.ts#L214-L245) @bca5070b
  - [arcade/spice-invaders.ts:295-345](https://github.com/nicobailon/pi-extensions/blob/bca5070b541ffa7d273e74036dcd7d5f8b63eed3/arcade/spice-invaders.ts#L295-L345) @bca5070b
  - [arcade/badlogic-game/badlogic-game.ts:22-96](https://github.com/nicobailon/pi-extensions/blob/bca5070b541ffa7d273e74036dcd7d5f8b63eed3/arcade/badlogic-game/badlogic-game.ts#L22-L96) @bca5070b
- [**pi-autoresearch**](https://github.com/nicobailon/pi-autoresearch): Pair live refresh with owned timer cleanup
  - [extensions/pi-autoresearch/index.ts:1428-1445](https://github.com/nicobailon/pi-autoresearch/blob/22bd30b19482f2be5936031b2c6b149115779f16/extensions/pi-autoresearch/index.ts#L1428-L1445) @22bd30b1
- [**pi-coordination**](https://github.com/nicobailon/pi-coordination): Pair live refresh with owned timer cleanup
  - [coordinate/dashboard.ts:910-917](https://github.com/nicobailon/pi-coordination/blob/7f32f5d9d597a31fa477a629dfc6dd26bc649214/coordinate/dashboard.ts#L910-L917) @7f32f5d9
  - [coordinate/dashboard.ts:1015-1018](https://github.com/nicobailon/pi-coordination/blob/7f32f5d9d597a31fa477a629dfc6dd26bc649214/coordinate/dashboard.ts#L1015-L1018) @7f32f5d9
- [**pi-messenger**](https://github.com/nicobailon/pi-messenger): Pair live refresh with owned timer cleanup
  - [overlay-coordinator.ts:85-119](https://github.com/nicobailon/pi-messenger/blob/09937ed647a1b07a3b595bf75943feacb80ff123/overlay-coordinator.ts#L85-L119) @09937ed6

## For agents: choose and check

Choose Tick Heart when your job matches its intent and applicability above.

Check your version:

- Render your version at 40, 60, 80 and 120 columns in the dark and light themes.
- Check width: no rendered line is wider than the terminal.
- Check style-leak: no line ends with colour, bold or a link still switched on.
- Check hard-coded-colour: every colour on screen comes from the active theme.
- Check height: the output fits in the rows the terminal has.
- This pattern animates. Check every frame of the animation, not only the first.
- Read the Pi 1.0.3 docs for [`ctx.ui.custom`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays), [`Component`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), [`TUI.requestRender`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), `Box` before you use them.
- Every reference frame gets the verdict its state expects.

Next actions: `related({ id: "tick-heart" })` lists what to read next, and `states({ id: "tick-heart", width: 40 })` returns every frame and verdict at one width. The [build-pi-tui-component skill](https://pi-tui.ratstack.sh/skills/build-pi-tui-component.md) walks through the whole loop.

## Related

- [Pause Latch](https://pi-tui.ratstack.sh/patterns/pause-latch.md): suspends change while preserving input

## Linked from

- [Pause Latch](https://pi-tui.ratstack.sh/patterns/pause-latch.md): advances the live state
- [Refresh Lease](https://pi-tui.ratstack.sh/patterns/refresh-lease.md): mutates local state on each tick
