# Tail Anchor

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

Also known as Follow-tail view.

## Intent

Follow new output only while the user remains at the bottom.

## Motivation

Interactive Shell's reattachment view follows the bottom only when the user has not scrolled up.

## Applicability

- Use this when a live output view must also permit reading earlier lines.

## Structure

```text
new output + at bottom -> follow
new output + scrolled up -> retain
return to bottom -> follow
```

## 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.
- [`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.
- [`KeybindingsManager.matches`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#handle-keyboard-input-and-focus): Resolves input against configurable action bindings.
- `Scroll position`: Decides whether appended output moves the viewport.

Pi component APIs: [`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), [`KeybindingsManager.matches`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#handle-keyboard-input-and-focus), `matchesKey`

## Consequences

- Streaming output and manual history reading can coexist.
- The view must track whether tail-following is currently active.

## Implementation

- Preserve manual scroll position while output grows.
- Recompute terminal sizing on reattachment or resize.

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

### Following output

`following`: Show synthetic appended lines with the viewport at the tail.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Tail Anchor, Following output, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/following-40-dark.849757471c27.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/following-40-dark.anim.b463d2df23b6.webp) ![Tail Anchor, Following output, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/following-40-light.c16e91310aa4.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/following-40-light.anim.43dfa36bb4c1.webp)
- 60 columns: ![Tail Anchor, Following output, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/following-60-dark.35e085e311a1.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/following-60-dark.anim.afbca56db0dd.webp) ![Tail Anchor, Following output, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/following-60-light.28c80737bc00.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/following-60-light.anim.a79b7e3ff960.webp)
- 80 columns: ![Tail Anchor, Following output, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/following-80-dark.c1bdc42c4591.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/following-80-dark.anim.082f89ae54d7.webp) ![Tail Anchor, Following output, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/following-80-light.782ba237e5b3.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/following-80-light.anim.e6e1aad7b3ef.webp)
- 120 columns: ![Tail Anchor, Following output, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/following-120-dark.86758a38906f.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/following-120-dark.anim.fd42ed799bf2.webp) ![Tail Anchor, Following output, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/following-120-light.f3c5425fdf9e.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/following-120-light.anim.ee7f1b684f96.webp)

### Reading history

`scrolled`: Scroll up and retain that viewport while new lines arrive.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Tail Anchor, Reading history, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-40-dark.7f667bd15d7a.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-40-dark.anim.50669ba29ca5.webp) ![Tail Anchor, Reading history, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-40-light.fc56911b0610.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-40-light.anim.17ded0e2bce1.webp)
- 60 columns: ![Tail Anchor, Reading history, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-60-dark.0c062cba53a1.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-60-dark.anim.0304fabb765b.webp) ![Tail Anchor, Reading history, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-60-light.448190bab5cf.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-60-light.anim.16349a25fbd8.webp)
- 80 columns: ![Tail Anchor, Reading history, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-80-dark.f1e0072a4e2c.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-80-dark.anim.94ae68cec2e3.webp) ![Tail Anchor, Reading history, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-80-light.7899d7832c60.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-80-light.anim.d37c3f172d27.webp)
- 120 columns: ![Tail Anchor, Reading history, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-120-dark.bdc5804862d5.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-120-dark.anim.2b7f663732e6.webp) ![Tail Anchor, Reading history, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-120-light.ea5782d0704b.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/scrolled-120-light.anim.c7f42f69eaa1.webp)

### Follow resumed

`resumed`: Return to the bottom and resume following appended output.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Tail Anchor, Follow resumed, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-40-dark.bfb5b9d832c0.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-40-dark.anim.58ddb9d2c1a7.webp) ![Tail Anchor, Follow resumed, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-40-light.4e728c56609d.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-40-light.anim.c695d570eb38.webp)
- 60 columns: ![Tail Anchor, Follow resumed, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-60-dark.6538e0a36710.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-60-dark.anim.f3710770424f.webp) ![Tail Anchor, Follow resumed, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-60-light.63876ba2fcb7.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-60-light.anim.8f3f7ec4dfde.webp)
- 80 columns: ![Tail Anchor, Follow resumed, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-80-dark.07a45601ed7e.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-80-dark.anim.6eda0bce9a37.webp) ![Tail Anchor, Follow resumed, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-80-light.6cdc60ac2ef9.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-80-light.anim.e14e30adff03.webp)
- 120 columns: ![Tail Anchor, Follow resumed, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-120-dark.93ccfda296cd.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-120-dark.anim.4154e11dc2f6.webp) ![Tail Anchor, Follow resumed, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-120-light.b2d2e012707e.webp) [animated](https://pi-tui.ratstack.sh/frames/tail-anchor/resumed-120-light.anim.91d8eada4e69.webp)

## Sample code

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

```ts
// Tail Anchor: append synthetic output, following only when already at the bottom.
import { matchesKey, truncateToWidth, type Component } from "@earendil-works/pi-tui";
import type { PatternStory } from "../../../src/pattern.ts";

export const story: PatternStory = {
  id: "tail-anchor",
  title: "Tail Anchor",
  kind: "component",
  apis: ["Component", "KeybindingsManager.matches", "matchesKey", "TUI.requestRender"],
  rows: 10,
  states: [
    { id: "following", label: "Following output", animated: true, steps: [{ type: "tick", ms: 600 }] },
    { id: "scrolled", label: "Reading history", animated: true, steps: [
      { type: "keys", data: "\x1b[A" }, { type: "keys", data: "\x1b[A" },
      { type: "tick", ms: 600 },
    ] },
    { id: "resumed", label: "Follow resumed", animated: true, steps: [
      { type: "keys", data: "\x1b[F" }, { type: "tick", ms: 600 },
    ] },
  ],
  setup({ theme, tui, keybindings }) {
    const stages = ["parse tokens", "check syntax", "compare snapshot", "assert boundary"];
    const output = Array.from({ length: 12 }, (_, i) => line(i));
    const windowRows = 5;
    let top = output.length - windowRows;
    function line(index: number) {
      return "check " + String(index + 1).padStart(2, "0") + " · " + stages[index % stages.length];
    }
    const bottom = () => Math.max(0, output.length - windowRows);
    const timer = setInterval(() => {
      const atTail = top === bottom(); // Test before appending, not after.
      output.push(line(output.length));
      if (atTail) top = bottom();
      tui.requestRender();
    }, 200);
    const viewport: Component & { dispose(): void } = {
      invalidate() {},
      dispose() { clearInterval(timer); },
      handleInput(data) {
        if (keybindings.matches(data, "tui.select.up")) top = Math.max(0, top - 1);
        if (keybindings.matches(data, "tui.select.down")) top = Math.min(bottom(), top + 1);
        if (matchesKey(data, "end")) top = bottom();
        tui.requestRender();
      },
      render(width) {
        const following = top === bottom();
        return [
          theme.fg("accent", "Tail Anchor · synthetic test output"),
          theme.fg(following ? "success" : "warning", following ? "FOLLOWING · new lines move the view" : "READING HISTORY · position held"),
          ...output.slice(top, top + windowRows).map(text => theme.fg("toolOutput", truncateToWidth(text, width))),
          theme.fg("muted", "Lines " + (top + 1) + "–" + (top + windowRows) + " of " + output.length),
          theme.fg("dim", truncateToWidth("↑↓ scroll · End return to live tail", width)),
        ];
      },
    };
    return viewport;
  },
};
```

## Known uses: seen in Nico's repos

- [**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 Tail Anchor 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.

- [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.
- [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 [`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), [`KeybindingsManager.matches`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#handle-keyboard-input-and-focus), `matchesKey` before you use them.
- Every reference frame gets the verdict its state expects.

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

- [Render Funnel](https://pi-tui.ratstack.sh/patterns/render-funnel.md): schedules redraws for appended output

## Linked from

No other pattern links here.
