# Render Funnel

> 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.

Presentation / Rendering and performance · `render-funnel` · [HTML](https://pi-tui.ratstack.sh/patterns/render-funnel/) · [JSON](https://pi-tui.ratstack.sh/patterns/render-funnel.json) · [all patterns](https://pi-tui.ratstack.sh/patterns.md)

Also known as Coalesced redraw.

## Intent

Collapse bursty redraw scheduling behind one pending timer.

## Motivation

Interactive Shell and Powerline Footer receive bursts of updates that share a pending redraw timer.

## Applicability

- Use this when many output events do not need separate scheduled redraws.

## Structure

```text
event burst -> pending timer
one callback -> requestRender
```

## Participants

- [`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.
- [`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.
- `Pending timer`: Combines a burst into one scheduled render request.

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

## Consequences

- Several events can share one scheduled redraw request.
- Pending-timer cancellation and flushing need explicit semantics.

## Implementation

- Cancel the pending timer on disposal.
- Pi also coalesces requestRender calls internally.

## 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.

### Burst of updates

`burst`: Show one synthetic event burst updating the latest visible output.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Render Funnel, Burst of updates, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/burst-40-dark.2d6aa7ec8f22.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/burst-40-dark.anim.4c4766cfe939.webp) ![Render Funnel, Burst of updates, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/burst-40-light.ff4cef5dd1d5.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/burst-40-light.anim.e290c244b209.webp)
- 60 columns: ![Render Funnel, Burst of updates, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/burst-60-dark.35be7a0c2a63.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/burst-60-dark.anim.e1742b3e47fd.webp) ![Render Funnel, Burst of updates, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/burst-60-light.e1cec7c32830.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/burst-60-light.anim.292b4f9ac6d4.webp)
- 80 columns: ![Render Funnel, Burst of updates, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/burst-80-dark.796dc43c0fbf.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/burst-80-dark.anim.213515e509fb.webp) ![Render Funnel, Burst of updates, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/burst-80-light.ed1e1cc5f90f.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/burst-80-light.anim.8d1b56325680.webp)
- 120 columns: ![Render Funnel, Burst of updates, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/burst-120-dark.f68237a40871.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/burst-120-dark.anim.ed980afff745.webp) ![Render Funnel, Burst of updates, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/burst-120-light.8f7d889d1261.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/burst-120-light.anim.b3c7bfbbf8a3.webp)

### Single scheduled redraw

`settled`: Display a render counter showing the burst shares one scheduled request.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Render Funnel, Single scheduled redraw, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/settled-40-dark.87320f5ef400.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/settled-40-dark.anim.ab53e52ac76c.webp) ![Render Funnel, Single scheduled redraw, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/settled-40-light.dc9242fccdb1.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/settled-40-light.anim.f45363af515f.webp)
- 60 columns: ![Render Funnel, Single scheduled redraw, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/settled-60-dark.a26559b19b25.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/settled-60-dark.anim.e76da39335c6.webp) ![Render Funnel, Single scheduled redraw, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/settled-60-light.81770623e8b2.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/settled-60-light.anim.0cb386b5815d.webp)
- 80 columns: ![Render Funnel, Single scheduled redraw, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/settled-80-dark.87ac67118246.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/settled-80-dark.anim.5f261edaf6d3.webp) ![Render Funnel, Single scheduled redraw, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/settled-80-light.d576dcbb8abc.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/settled-80-light.anim.0c915b8a9f45.webp)
- 120 columns: ![Render Funnel, Single scheduled redraw, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/settled-120-dark.16f8ae08b916.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/settled-120-dark.anim.16da5bdfc599.webp) ![Render Funnel, Single scheduled redraw, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/settled-120-light.a5880548e3cd.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/settled-120-light.anim.0e1d258d5f0e.webp)

### Closed before timer

`closed`: Show the host unchanged after a pending redraw is cancelled.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Render Funnel, Closed before timer, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/closed-40-dark.91151b2fdef6.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/closed-40-dark.anim.841f9b426947.webp) ![Render Funnel, Closed before timer, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/closed-40-light.f3380e5791b2.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/closed-40-light.anim.6e87a10b33c9.webp)
- 60 columns: ![Render Funnel, Closed before timer, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/closed-60-dark.f366bc66b3c7.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/closed-60-dark.anim.0c4c3a03d84f.webp) ![Render Funnel, Closed before timer, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/closed-60-light.9bb3f7c89b8b.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/closed-60-light.anim.fe46b1e163f4.webp)
- 80 columns: ![Render Funnel, Closed before timer, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/closed-80-dark.29dcb4cb00d5.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/closed-80-dark.anim.de7d1aa3cc77.webp) ![Render Funnel, Closed before timer, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/closed-80-light.ea2f5f301818.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/closed-80-light.anim.5c7da5ab7758.webp)
- 120 columns: ![Render Funnel, Closed before timer, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/render-funnel/closed-120-dark.dc0ec8ce975c.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/closed-120-dark.anim.9bc132fb1f84.webp) ![Render Funnel, Closed before timer, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/render-funnel/closed-120-light.9eb1bd2b9eaf.webp) [animated](https://pi-tui.ratstack.sh/frames/render-funnel/closed-120-light.anim.ec125bb394bb.webp)

## Sample code

`stories/patterns/presentation/render-funnel.ts`, the story the frames above were rendered from.

```ts
// Render Funnel: one pending timer combines a burst into one redraw request.
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
import type { PatternStory } from "../../../src/pattern.ts";

type Schedule =
  | { kind: "idle" }
  | { kind: "pending"; timer: ReturnType<typeof setTimeout> }
  | { kind: "closed" };
// Lifecycle: idle --event--> pending --flush--> idle.
// More events stay pending; close cancels its timer and enters closed.
export const story: PatternStory = {
  id: "render-funnel", title: "Render Funnel", kind: "screen",
  apis: ["Component", "TUI.requestRender", "ctx.ui.setWidget"],
  states: [
    { id: "burst", label: "Burst of updates", animated: true, steps: [
      { type: "action", name: "burst" }, { type: "tick", ms: 500 },
    ] },
    { id: "settled", label: "Single scheduled redraw", animated: true, steps: [
      { type: "tick", ms: 200 }, { type: "action", name: "assert-settled" },
    ] },
    { id: "closed", label: "Closed before timer", animated: true, steps: [
      { type: "action", name: "close-pending" }, { type: "tick", ms: 700 },
      { type: "action", name: "assert-closed" },
    ] },
  ],
  setup({ ui, theme, tui, action }) {
    let schedule: Schedule = { kind: "idle" };
    let events = 0, requests = 0, latest = "Waiting for task output";
    const arrivals: ReturnType<typeof setTimeout>[] = [];
    function receive(text: string) {
      if (schedule.kind === "closed") return;
      latest = text; events++;
      if (schedule.kind === "pending") return;
      schedule = { kind: "pending", timer: setTimeout(() => {
        schedule = { kind: "idle" }; requests++; tui.requestRender();
      }, 600) };
    }
    function close() {
      if (schedule.kind === "pending") clearTimeout(schedule.timer);
      schedule = { kind: "closed" };
    }
    ui.setEditorText("Keep the task draft");
    ui.setWidget("output", () => ({
      invalidate() {},
      render(width) {
        const lines = ["Task output · " + schedule.kind, latest,
          events + " events → " + requests + " scheduled requests",
          schedule.kind === "closed" ? "Timer cancelled · draft stays intact" :
            schedule.kind === "pending" ? "Burst shares one pending timer" : "Latest output flushed"];
        return lines.flatMap((line, index) => wrapTextWithAnsi(line, width)
          .map(row => theme.fg(index === 0 ? "accent" : "muted", row)));
      },
    }));
    action("burst", () => {
      receive("Parser task started");
      arrivals.push(setTimeout(() => receive("Parser task: 3 checks"), 200));
      arrivals.push(setTimeout(() => receive("Parser task: 6 checks passed"), 400));
    });
    action("assert-settled", () => {
      if (events !== 3 || requests !== 1 || schedule.kind !== "idle") throw new Error("Burst did not coalesce");
    });
    action("close-pending", () => { receive("Review task queued"); close(); tui.requestRender(); });
    action("assert-closed", () => {
      if (requests !== 1 || schedule.kind !== "closed" || ui.getEditorText() !== "Keep the task draft")
        throw new Error("Cancelled timer changed the host");
    });
    return () => { close(); arrivals.forEach(clearTimeout); ui.setWidget("output", undefined); };
  },
};
```

## Known uses: seen in Nico's repos

- [**pi-interactive-shell**](https://github.com/nicobailon/pi-interactive-shell): Coalesce bursty redraw requests
  - [overlay-component.ts:263-273](https://github.com/nicobailon/pi-interactive-shell/blob/77df9a8142a2f731635a4c5a01d68feecb5cced4/overlay-component.ts#L263-L273) @77df9a81
  - [overlay-component.ts:1098-1125](https://github.com/nicobailon/pi-interactive-shell/blob/77df9a8142a2f731635a4c5a01d68feecb5cced4/overlay-component.ts#L1098-L1125) @77df9a81
- [**pi-powerline-footer**](https://github.com/nicobailon/pi-powerline-footer): Coalesce bursty redraw requests
  - [render-scheduler.ts:1-28](https://github.com/nicobailon/pi-powerline-footer/blob/859dee671b633fb533b07ceba3e6c1ab1c43360a/render-scheduler.ts#L1-L28) @859dee67
- [**pi-interactive-shell**](https://github.com/nicobailon/pi-interactive-shell): Reattach overlay supports bounded selection and deferred refresh
  - [reattach-overlay.ts:18-115](https://github.com/nicobailon/pi-interactive-shell/blob/77df9a8142a2f731635a4c5a01d68feecb5cced4/reattach-overlay.ts#L18-L115) @77df9a81
  - [reattach-overlay.ts:311-448](https://github.com/nicobailon/pi-interactive-shell/blob/77df9a8142a2f731635a4c5a01d68feecb5cced4/reattach-overlay.ts#L311-L448) @77df9a81

## For agents: choose and check

Choose Render Funnel when your job matches its intent and applicability above. Its neighbours in Rendering and performance are listed below. Read the one whose intent fits your job more closely before you commit.

- [Tail Anchor](https://pi-tui.ratstack.sh/patterns/tail-anchor.md): Follow new output only while the user remains at the bottom.
- [Refresh Lease](https://pi-tui.ratstack.sh/patterns/refresh-lease.md): Pair asynchronous refresh triggers with component-owned cleanup.
- [Late Paint](https://pi-tui.ratstack.sh/patterns/late-paint.md): Cache plain layout by width and apply theme styling during rendering.

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 [`TUI.requestRender`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), [`Component`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), `ctx.ui.setWidget` before you use them.
- Every reference frame gets the verdict its state expects.

Next actions: `related({ id: "render-funnel" })` lists what to read next, and `states({ id: "render-funnel", 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

- [Refresh Lease](https://pi-tui.ratstack.sh/patterns/refresh-lease.md): owns the redraw trigger's lifetime

## Linked from

- [Tail Anchor](https://pi-tui.ratstack.sh/patterns/tail-anchor.md): schedules redraws for appended output
