# Identity 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 / Lists and pickers · `identity-anchor` · [HTML](https://pi-tui.ratstack.sh/patterns/identity-anchor/) · [JSON](https://pi-tui.ratstack.sh/patterns/identity-anchor.json) · [all patterns](https://pi-tui.ratstack.sh/patterns.md)

Also known as Stable async selection.

## Intent

Retain the highlighted item's identity as asynchronous results arrive.

## Motivation

Intercom's handover picker receives remote listings after local choices are already visible.

## Applicability

- Use this when a picker combines immediately available and remote options.

## Structure

```text
local + remote results
        -> merge by identity
        -> keep highlight
```

## 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.
- [`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.
- `Item identity`: Anchors the highlight through asynchronous insertions.

Pi component 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), [`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), `SelectList`

## Consequences

- New arrivals do not move the highlight to a different item.
- Pending, failed and late responses need explicit handling.

## Implementation

- Ignore responses after the view closes.
- Do not present pending requests as completed entries.

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

### Loading options

`loading`: Show local items and a visibly pending remote section.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Identity Anchor, Loading options, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/loading-40-dark.9a7fc2c20d4e.webp) ![Identity Anchor, Loading options, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/loading-40-light.92e432399b5a.webp)
- 60 columns: ![Identity Anchor, Loading options, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/loading-60-dark.8f20c837dcac.webp) ![Identity Anchor, Loading options, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/loading-60-light.245f041fb0b3.webp)
- 80 columns: ![Identity Anchor, Loading options, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/loading-80-dark.abcd48047154.webp) ![Identity Anchor, Loading options, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/loading-80-light.8f3d90dffbab.webp)
- 120 columns: ![Identity Anchor, Loading options, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/loading-120-dark.f781e401eb38.webp) ![Identity Anchor, Loading options, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/loading-120-light.c256e2bd95e2.webp)

### Selection retained

`arrived`: Insert remote results while preserving the highlighted item.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Identity Anchor, Selection retained, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/arrived-40-dark.f5d8b9c16556.webp) ![Identity Anchor, Selection retained, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/arrived-40-light.76f7521b0c03.webp)
- 60 columns: ![Identity Anchor, Selection retained, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/arrived-60-dark.1cd299209bde.webp) ![Identity Anchor, Selection retained, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/arrived-60-light.c28bda4916f0.webp)
- 80 columns: ![Identity Anchor, Selection retained, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/arrived-80-dark.38b4922b6c91.webp) ![Identity Anchor, Selection retained, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/arrived-80-light.8fb51b048807.webp)
- 120 columns: ![Identity Anchor, Selection retained, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/arrived-120-dark.88f1add69c22.webp) ![Identity Anchor, Selection retained, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/arrived-120-light.26432e017a84.webp)

### Remote error

`error`: Show a settled error section while keeping local choices usable.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Identity Anchor, Remote error, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/error-40-dark.a8075661443d.webp) ![Identity Anchor, Remote error, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/error-40-light.8ae5c0539ac1.webp)
- 60 columns: ![Identity Anchor, Remote error, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/error-60-dark.e4419ec8e1ef.webp) ![Identity Anchor, Remote error, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/error-60-light.dadafc86e98a.webp)
- 80 columns: ![Identity Anchor, Remote error, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/error-80-dark.50602199f324.webp) ![Identity Anchor, Remote error, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/error-80-light.228265cf6d1a.webp)
- 120 columns: ![Identity Anchor, Remote error, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/identity-anchor/error-120-dark.847102719bc9.webp) ![Identity Anchor, Remote error, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/identity-anchor/error-120-light.b00ef9d17565.webp)

## Sample code

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

```ts
// Identity Anchor: preserve an item's identity when asynchronous rows arrive.
import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
import { SelectList, Text, type SelectItem } from "@earendil-works/pi-tui";
import type { PatternStory } from "../../../src/pattern.ts";

const local = [
  { value: "local-build", label: "Build", description: "Local task" },
  { value: "local-review", label: "Review", description: "Local task" },
];
type RemoteSection = { status: "pending" } | { status: "arrived"; items: SelectItem[] } | { status: "error"; message: string };

export const story: PatternStory = {
  id: "identity-anchor", title: "Identity Anchor", kind: "component",
  apis: ["Component", "SelectList", "TUI.requestRender"], rows: 12,
  states: [
    { id: "loading", label: "Loading options" },
    { id: "arrived", label: "Selection retained", steps: [{ type: "action", name: "arrive" }] },
    { id: "error", label: "Remote error", steps: [{ type: "action", name: "error" }] },
  ],
  setup({ tui, theme, action }) {
    let remote: RemoteSection = { status: "pending" };
    let lifecycle: "open" | "closed" = "open";
    let choices = new SelectList(local, 5, getSelectListTheme());
    choices.setSelectedIndex(1);
    function accept(response: RemoteSection) {
      if (lifecycle === "closed") return; // Late responses cannot revive the view.
      const identity = choices.getSelectedItem()?.value;
      remote = response;
      const merged = remote.status === "arrived" ? [...remote.items, ...local] : local;
      choices = new SelectList(merged, 5, getSelectListTheme());
      choices.setSelectedIndex(Math.max(0, merged.findIndex(item => item.value === identity)));
      tui.requestRender();
    }
    action("arrive", () => accept({ status: "arrived", items: [
      { value: "remote-plan", label: "Plan", description: "Remote task" },
      { value: "remote-test", label: "Test", description: "Remote task" },
    ] }));
    action("error", () => accept({ status: "error", message: "Remote failed · local tasks still usable" }));
    return {
      invalidate() { choices.invalidate(); },
      dispose() { lifecycle = "closed"; },
      handleInput(data) { choices.handleInput(data); tui.requestRender(); },
      render(width) {
        const section = remote.status === "pending" ? "Remote · listing…"
          : remote.status === "arrived" ? "Remote · 2 tasks arrived above locals" : remote.message;
        return [
          ...new Text(theme.fg("accent", "Handover target"), 0, 0).render(width),
          ...new Text(theme.fg(remote.status === "error" ? "warning" : "muted", section), 0, 0).render(width),
          "", ...choices.render(width), "",
          ...new Text(theme.fg("success", "Anchored id: " + choices.getSelectedItem()?.value), 0, 0).render(width),
          ...new Text(theme.fg("dim", "New rows do not steal the highlight"), 0, 0).render(width),
        ];
      },
    };
  },
};
```

## Known uses: seen in Nico's repos

- [**pi-intercom**](https://github.com/nicobailon/pi-intercom): Separate task-field focus and retain remote selection through async refresh
  - [ui/handover-picker.ts:61-180](https://github.com/nicobailon/pi-intercom/blob/a5fad4df2a9fe4909bf4d9b06263c8316976b57d/ui/handover-picker.ts#L61-L180) @a5fad4df
- [**pi-subagents**](https://github.com/nicobailon/pi-subagents): Keep fleet selection stable while details are derived
  - [src/tui/fleet.ts:1-120](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/tui/fleet.ts#L1-L120) @6826b054
  - [src/tui/fleet.ts:1360-1463](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/tui/fleet.ts#L1360-L1463) @6826b054

## For agents: choose and check

Choose Identity Anchor when your job matches its intent and applicability above. Its neighbours in Lists and pickers are listed below. Read the one whose intent fits your job more closely before you commit.

- [Row Window](https://pi-tui.ratstack.sh/patterns/row-window.md): Keep the selected item inside a bounded moving list window.
- [Detail Lens](https://pi-tui.ratstack.sh/patterns/detail-lens.md): Derive a selected item's detail sections from a read-only snapshot.
- [Tab Deck](https://pi-tui.ratstack.sh/patterns/tab-deck.md): Keep separate keyboard-navigable data views behind a tab strip.
- [Branch Fold](https://pi-tui.ratstack.sh/patterns/branch-fold.md): Retain validated fold identifiers while navigating a session tree.
- [Match Ladder](https://pi-tui.ratstack.sh/patterns/match-ladder.md): Rank matching options across weighted label and description fields.
- [Shrinking Sieve](https://pi-tui.ratstack.sh/patterns/shrinking-sieve.md): Narrow existing candidates while a search query grows.
- [Preview Basket](https://pi-tui.ratstack.sh/patterns/preview-basket.md): Keep selection separate from thumbnail loading and zoom inspection.
- [Lazy Peek](https://pi-tui.ratstack.sh/patterns/lazy-peek.md): Load and cache preview detail only for items the user inspects.

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

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

- [Field Baton](https://pi-tui.ratstack.sh/patterns/field-baton.md): separates target and task input

## Linked from

- [Row Window](https://pi-tui.ratstack.sh/patterns/row-window.md): preserves identity when items change
- [Detail Lens](https://pi-tui.ratstack.sh/patterns/detail-lens.md): keeps the selected identity stable
- [Field Baton](https://pi-tui.ratstack.sh/patterns/field-baton.md): keeps target identity through refresh
