# Match Ladder

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

Also known as Ranked picker.

## Intent

Rank matching options across weighted label and description fields.

## Motivation

Subagents ranks matches across name, description and model rather than matching only one label.

## Applicability

- Use this when substring and ordered-character matches need different priorities.

## Structure

```text
query + fields -> scores
scores -> order -> selection
```

## Participants

- [`Input`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#compose-built-in-components): Owns single-line editing and cursor state.
- [`SelectList`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#compose-built-in-components): Owns a bounded selection list and selection callbacks.
- [`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.
- `Match scorer`: Weights fields and orders positive matches.

Pi component APIs: [`Input`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#compose-built-in-components), [`SelectList`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#compose-built-in-components), [`KeybindingsManager.matches`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#handle-keyboard-input-and-focus)

## Consequences

- Field weights distinguish stronger matches from weaker ones.
- Ranking and stable selection require more logic than a plain filter.

## Implementation

- Subagents and skill-palette use different search algorithms.
- Keep the highlighted item stable where it remains in the results.

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

### Ranked matches

`query`: Show substring matches before weaker subsequence matches across synthetic fields.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Match Ladder, Ranked matches, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/query-40-dark.14fd5c0ddd84.webp) ![Match Ladder, Ranked matches, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/query-40-light.d11ece494259.webp)
- 60 columns: ![Match Ladder, Ranked matches, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/query-60-dark.d14bbd44d577.webp) ![Match Ladder, Ranked matches, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/query-60-light.4a027953086e.webp)
- 80 columns: ![Match Ladder, Ranked matches, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/query-80-dark.e2ece7aa00c4.webp) ![Match Ladder, Ranked matches, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/query-80-light.084c325f3c11.webp)
- 120 columns: ![Match Ladder, Ranked matches, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/query-120-dark.c53984c40c8f.webp) ![Match Ladder, Ranked matches, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/query-120-light.2be30367c5a4.webp)

### Keep selection

`navigation`: Move down without resetting selection to the first row.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Match Ladder, Keep selection, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/navigation-40-dark.b91f0b1ee16a.webp) ![Match Ladder, Keep selection, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/navigation-40-light.fb0fe09fa881.webp)
- 60 columns: ![Match Ladder, Keep selection, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/navigation-60-dark.52a408894c47.webp) ![Match Ladder, Keep selection, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/navigation-60-light.7f7863a5ad9a.webp)
- 80 columns: ![Match Ladder, Keep selection, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/navigation-80-dark.a173ced4253d.webp) ![Match Ladder, Keep selection, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/navigation-80-light.83e0a0c1c459.webp)
- 120 columns: ![Match Ladder, Keep selection, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/navigation-120-dark.0de6b4ba9fdc.webp) ![Match Ladder, Keep selection, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/navigation-120-light.cb9a0fdaae52.webp)

### No matches

`empty`: Show an explicit empty-result row.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Match Ladder, No matches, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/empty-40-dark.20e0cbcd3d1b.webp) ![Match Ladder, No matches, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/empty-40-light.6918c7fecd48.webp)
- 60 columns: ![Match Ladder, No matches, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/empty-60-dark.1b09960eb377.webp) ![Match Ladder, No matches, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/empty-60-light.6828cc917199.webp)
- 80 columns: ![Match Ladder, No matches, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/empty-80-dark.c866550da49f.webp) ![Match Ladder, No matches, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/empty-80-light.0a41260fe076.webp)
- 120 columns: ![Match Ladder, No matches, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/match-ladder/empty-120-dark.72fa9a73b77c.webp) ![Match Ladder, No matches, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/match-ladder/empty-120-light.7d6a4309b57f.webp)

## Sample code

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

```ts
// Match Ladder: rank weighted fields, then retain the selected identity.
import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
import { Input, SelectList, Text, type SelectItem, type Component, type Focusable } from "@earendil-works/pi-tui";
import type { PatternStory } from "../../../src/pattern.ts";

const options = [
  { value: "review", label: "Review", description: "Inspect a patch", model: "balanced" },
  { value: "audit", label: "Audit", description: "Review dependencies", model: "careful" },
  { value: "research", label: "Retrieve evidence", description: "Search notes", model: "rapid" },
];
function matchScore(field: string, query: string): number {
  field = field.toLowerCase();
  if (field.includes(query)) return 10;
  let cursor = 0;
  for (const letter of field) if (letter === query[cursor]) cursor++;
  return cursor === query.length ? 1 : 0;
}
function ranked(query: string): SelectItem[] {
  return options.map(item => ({ item, score: Math.max(
    3 * matchScore(item.label, query), 2 * matchScore(item.description, query),
    matchScore(item.model, query),
  ) })).filter(match => match.score > 0)
    .sort((a, b) => b.score - a.score).map(({ item, score }) => ({
      ...item, label: score + " · " + item.label,
    }));
}

export const story: PatternStory = {
  id: "match-ladder", title: "Match Ladder", kind: "component",
  apis: ["Input", "SelectList", "KeybindingsManager.matches"], rows: 12,
  states: [
    { id: "query", label: "Ranked matches" },
    { id: "navigation", label: "Keep selection", steps: [
      { type: "keys", data: "\x1b[B" }, { type: "action", name: "refresh" },
    ] },
    { id: "empty", label: "No matches", steps: [{ type: "keys", data: "zzz" }] },
  ],
  setup({ tui, theme, keybindings, action }) {
    const input = new Input({ prompt: "Search: " });
    input.setValue("rev");
    input.handleInput("\x05"); // Move to the end of the initial query.
    let choices = new SelectList(ranked(input.getValue()), 5, getSelectListTheme());
    function refresh() {
      const identity = choices.getSelectedItem()?.value;
      const matches = ranked(input.getValue().toLowerCase());
      choices = new SelectList(matches, 5, getSelectListTheme());
      choices.setSelectedIndex(Math.max(0, matches.findIndex(item => item.value === identity)));
      tui.requestRender();
    }
    action("refresh", refresh);
    const view: Component & Focusable = {
      focused: false,
      invalidate() { input.invalidate(); choices.invalidate(); },
      handleInput(data) {
        if (keybindings.matches(data, "tui.select.up") || keybindings.matches(data, "tui.select.down")) {
          choices.handleInput(data);
        } else { input.handleInput(data); refresh(); }
        tui.requestRender();
      },
      render(width) {
        input.focused = this.focused;
        return [
          ...new Text(theme.fg("accent", "Task picker · strongest field wins"), 0, 0).render(width),
          ...input.render(width), "", ...choices.render(width), "",
          ...new Text(theme.fg("muted", "Label ×3 · description ×2 · model ×1"), 0, 0).render(width),
          ...new Text(theme.fg("dim", "Substring outranks ordered characters"), 0, 0).render(width),
        ];
      },
    };
    return view;
  },
};
```

## Known uses: seen in Nico's repos

- [**pi-subagents**](https://github.com/nicobailon/pi-subagents): Rank selector choices by best matching field
  - [src/tui/render-helpers.ts:1-29](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/tui/render-helpers.ts#L1-L29) @6826b054
  - [src/slash/selector.ts:85-117](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/slash/selector.ts#L85-L117) @6826b054

## For agents: choose and check

Choose Match Ladder 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.
- [Shrinking Sieve](https://pi-tui.ratstack.sh/patterns/shrinking-sieve.md): Narrow existing candidates while a search query grows.
- [Identity Anchor](https://pi-tui.ratstack.sh/patterns/identity-anchor.md): Retain the highlighted item's identity as asynchronous results arrive.
- [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 [`Input`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#compose-built-in-components), [`SelectList`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#compose-built-in-components), [`KeybindingsManager.matches`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#handle-keyboard-input-and-focus) before you use them.
- Every reference frame gets the verdict its state expects.

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

- [Shrinking Sieve](https://pi-tui.ratstack.sh/patterns/shrinking-sieve.md): narrows candidates without field ranking

## Linked from

- [Shrinking Sieve](https://pi-tui.ratstack.sh/patterns/shrinking-sieve.md): ranks rather than only narrowing
