# Late Paint

> 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 · `late-paint` · [HTML](https://pi-tui.ratstack.sh/patterns/late-paint/) · [JSON](https://pi-tui.ratstack.sh/patterns/late-paint.json) · [all patterns](https://pi-tui.ratstack.sh/patterns.md)

Also known as Width-keyed cache.

## Intent

Cache plain layout by width and apply theme styling during rendering.

## Motivation

Intercom's inline messages reuse immutable wrapped text across widths and apply colours while rendering.

## Applicability

- Use this when immutable message content needs repeated wrapping or preview rendering.

## Structure

```text
immutable text + width -> cache
cached layout + theme -> lines
```

## Participants

- [`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.
- [`wrapTextWithAnsi`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model): Wraps text while preserving styling across lines.
- [`theme.style`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#apply-themes-correctly): Styles text through semantic or concrete colours.
- `Plain layout cache`: Retains immutable wrapping without embedding theme colours.

Pi component APIs: [`Component`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), [`wrapTextWithAnsi`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), [`theme.style`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#apply-themes-correctly), `getThemeByName`

## Consequences

- Cached layout can survive theme changes without retaining old colours.
- Content mutation or width changes require cache invalidation.

## Implementation

- Invalidate layout when its content or width changes.
- Invalidation must not erase application state.
- Do not cache permanently coloured strings.

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

### Cached layout

`cached`: Render an immutable synthetic message using a width-keyed wrapped layout.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Late Paint, Cached layout, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/cached-40-dark.1121f526f3d3.webp) ![Late Paint, Cached layout, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/cached-40-light.aebb9aa3f897.webp)
- 60 columns: ![Late Paint, Cached layout, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/cached-60-dark.4d38ad8c15fa.webp) ![Late Paint, Cached layout, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/cached-60-light.cdfb56bd28f2.webp)
- 80 columns: ![Late Paint, Cached layout, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/cached-80-dark.78f73c773210.webp) ![Late Paint, Cached layout, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/cached-80-light.c69bf44eb85d.webp)
- 120 columns: ![Late Paint, Cached layout, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/cached-120-dark.14f607749372.webp) ![Late Paint, Cached layout, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/cached-120-light.e0c54de6dae0.webp)

### Width changed

`resized`: Change width and rebuild wrapping without clearing the message.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Late Paint, Width changed, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/resized-40-dark.b95859643210.webp) ![Late Paint, Width changed, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/resized-40-light.11cb037cdb68.webp)
- 60 columns: ![Late Paint, Width changed, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/resized-60-dark.36b04076f4a9.webp) ![Late Paint, Width changed, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/resized-60-light.eeb823c4dd32.webp)
- 80 columns: ![Late Paint, Width changed, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/resized-80-dark.f0230947cacc.webp) ![Late Paint, Width changed, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/resized-80-light.f06412fc6855.webp)
- 120 columns: ![Late Paint, Width changed, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/resized-120-dark.e707bd23e4fe.webp) ![Late Paint, Width changed, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/resized-120-light.c7ab8f42e652.webp)

### Theme changed

`theme-change`: Reapply the active theme without retaining old ANSI colours.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Late Paint, Theme changed, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/theme-change-40-dark.c16d190a3008.webp) ![Late Paint, Theme changed, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/theme-change-40-light.785f696dfce1.webp)
- 60 columns: ![Late Paint, Theme changed, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/theme-change-60-dark.e71318b4bcef.webp) ![Late Paint, Theme changed, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/theme-change-60-light.cac80a950c99.webp)
- 80 columns: ![Late Paint, Theme changed, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/theme-change-80-dark.11cad50ab57d.webp) ![Late Paint, Theme changed, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/theme-change-80-light.ac63730bdca3.webp)
- 120 columns: ![Late Paint, Theme changed, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/late-paint/theme-change-120-dark.fe8d503f4e8e.webp) ![Late Paint, Theme changed, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/late-paint/theme-change-120-light.69c4a1b98dfc.webp)

## Sample code

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

```ts
// Late Paint: cache plain width-keyed layout; apply the active theme last.
import { wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
import { Theme } from "@earendil-works/pi-coding-agent";
import type { PatternStory } from "../../../src/pattern.ts";

export const story: PatternStory = {
  id: "late-paint", title: "Late Paint", kind: "component",
  apis: ["Component", "wrapTextWithAnsi", "theme.style", "getThemeByName"], rows: 12,
  states: [
    { id: "cached", label: "Cached layout" },
    { id: "resized", label: "Width changed", steps: [{ type: "action", name: "resize-probe" }] },
    { id: "theme-change", label: "Theme round trip", steps: [{ type: "action", name: "theme-probe" }] },
  ],
  async setup({ theme, tui, action }) {
    // This shipped helper is not a root export in Pi 1.0.3.
    const module = await import(new URL("./modes/interactive/theme/theme.js",
      import.meta.resolve("@earendil-works/pi-coding-agent")).href);
    const opposite: unknown = module.getThemeByName(theme.appearance === "dark" ? "light" : "dark");
    if (!(opposite instanceof Theme)) throw new Error("Pinned theme missing");
    const message = "Review the parser changes before the next build. Keep the wrapped message when the terminal narrows; repaint it when the theme changes.";
    const layoutCache = new Map<number, string[]>();
    let activeTheme = theme, note = "Plain wrapping reused on repeat render";
    const view: Component = {
      invalidate() { layoutCache.clear(); },
      render(width) {
        if (!layoutCache.has(width)) layoutCache.set(width, wrapTextWithAnsi(message, width));
        return [
          ...wrapTextWithAnsi("Parser review · immutable message", width).map(line =>
            activeTheme.style(line, { fg: "accent", bold: true })),
          "",
          ...layoutCache.get(width)!.map(line => activeTheme.style(line, { fg: "text" })),
          "",
          ...wrapTextWithAnsi(note, width).map(line => activeTheme.fg("muted", line)),
        ];
      },
    };
    action("resize-probe", () => {
      view.invalidate();
      view.render(Math.max(20, Math.floor(tui.terminal.columns / 2)));
      note = "Narrow layout rebuilt; message retained";
      tui.requestRender();
    });
    action("theme-probe", () => {
      // Check the opposite theme offscreen; capture the supplied matrix theme.
      activeTheme = opposite;
      view.render(tui.terminal.columns);
      activeTheme = theme;
      note = "Theme round trip · same plain layout";
      tui.requestRender();
    });
    return view;
  },
};
```

## Known uses: seen in Nico's repos

- [**pi-intercom**](https://github.com/nicobailon/pi-intercom): Cache message layout separately from live theme styling
  - [ui/inline-message.ts:10-125](https://github.com/nicobailon/pi-intercom/blob/a5fad4df2a9fe4909bf4d9b06263c8316976b57d/ui/inline-message.ts#L10-L125) @a5fad4df
- [**pi-subagents**](https://github.com/nicobailon/pi-subagents): Project workflow state before rendering status widgets
  - [src/tui/render.ts:2520-2585](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/tui/render.ts#L2520-L2585) @6826b054
  - [src/tui/render.ts:3060-3110](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/tui/render.ts#L3060-L3110) @6826b054

## For agents: choose and check

Choose Late Paint 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.
- [Render Funnel](https://pi-tui.ratstack.sh/patterns/render-funnel.md): Collapse bursty redraw scheduling behind one pending timer.

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.
- Read the Pi 1.0.3 docs for [`Component`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), [`wrapTextWithAnsi`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), [`theme.style`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#apply-themes-correctly), `getThemeByName` before you use them.
- Every reference frame gets the verdict its state expects.

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

- [Lazy Peek](https://pi-tui.ratstack.sh/patterns/lazy-peek.md): caches loaded data instead of layout

## Linked from

- [Lazy Peek](https://pi-tui.ratstack.sh/patterns/lazy-peek.md): caches layout rather than fetched detail
- [Message Fold](https://pi-tui.ratstack.sh/patterns/message-fold.md): reuses wrapped message layout
