Dynamic Menus

This guide shows practical patterns for driving pok dynamic menus from a data source. The examples use piq, a query engine for markdown collections, but the provider contract is the same for any async source — a database, an HTTP API, or the filesystem.

The provider contract

A dynamic select takes a provider: a single async function that receives the current type-ahead filter and an AbortSignal, and resolves to the full array of options to display. The UI adapter owns loading, debounce, and filtering presentation — those are not part of the contract.

type OptionsProvider<T> = (
  filter: string | undefined,
  signal: AbortSignal
) => Promise<SelectOption<T>[]>;

type SelectOption<T> = {
  value: T;
  label: string;
  hint?: string;
  group?: string; // optional visual grouping
};

Prerequisites

  • pok dynamic menus (provider on prompter.select())
  • a piq collection/resolver configured in your app (for the examples below; piq queries select fields via the params.*, frontmatter.*, and body.* namespaces)

Basic: Collection to Menu

import { piq } from 'piqit';
import type { OptionsProvider } from '@pokit/core';

const postsProvider: OptionsProvider<string> = async (filter, signal) => {
  const results = await piq
    .from(posts)
    .scan({})
    .select('params.slug', 'frontmatter.title', 'params.year')
    .exec();

  return results.map((r) => ({
    value: r.slug,
    label: r.title ?? r.slug,
    hint: r.year,
  }));
};

const selected = await prompter.select({
  message: 'Select a post to edit',
  provider: postsProvider,
});

Typeahead: Server-Side Filtering

The provider is re-invoked with the current filter. Use piq's scan() for param constraints and filter() for frontmatter constraints, and forward the signal so in-flight queries are cancelled.

import { piq } from 'piqit';
import type { OptionsProvider } from '@pokit/core';

const filterableProvider: OptionsProvider<string> = async (filter, signal) => {
  const query = piq.from(posts).scan({});

  if (filter) {
    query.scan({ tag: filter });
  }

  const results = await query
    .select('params.slug', 'params.tag', 'frontmatter.title')
    .exec();

  return results.map((r) => ({
    value: r.slug,
    label: r.title ?? r.slug,
    hint: r.tag,
  }));
};

const selected = await prompter.select({
  message: 'Search posts by tag',
  provider: filterableProvider,
});

Frontmatter-driven filter example:

type PostStatus = 'draft' | 'published' | 'archived';

const statusFilterProvider: OptionsProvider<string> = async (filter) => {
  const query = piq.from(posts).scan({});

  if (filter && ['draft', 'published', 'archived'].includes(filter)) {
    query.filter({ status: filter as PostStatus });
  }

  const results = await query
    .select('params.slug', 'frontmatter.title', 'frontmatter.status')
    .exec();

  return results.map((r) => ({
    value: r.slug,
    label: r.title ?? r.slug,
    hint: r.status,
  }));
};

Grouping

Use the group field to visually cluster options (like an <optgroup>):

const groupedProvider: OptionsProvider<string> = async () => {
  const rows = await piq
    .from(posts)
    .scan({})
    .select('params.slug', 'frontmatter.title', 'params.year')
    .exec();

  return rows.map((r) => ({
    value: r.slug,
    label: r.title ?? r.slug,
    group: r.year, // options with the same group render together
  }));
};

Layered Resolution Pattern

Load only what the menu needs. Pull heavier fields later, after selection.

const selected = await prompter.select({
  message: 'Select a post',
  provider: async () => {
    const rows = await piq
      .from(posts)
      .scan({ year: '2026' })
      .select('params.slug', 'frontmatter.title')
      .exec();

    return rows.map((r) => ({ value: r.slug, label: r.title ?? r.slug }));
  },
});

// Follow-up query: heavier body fields only for the selected row
const post = await piq
  .from(posts)
  .scan({ slug: selected })
  .select('params.slug', 'frontmatter.title', 'body.html', 'body.headings')
  .single()
  .exec();

Hierarchical Menus from Path Params

const categoryProvider: OptionsProvider<string> = async () => {
  const rows = await piq.from(docs).scan({}).select('params.category').exec();
  const categories = [...new Set(rows.map((r) => r.category))];

  return categories.map((category) => ({
    value: category,
    label: category.replace(/-/g, ' '),
  }));
};

const docsInCategoryProvider =
  (category: string): OptionsProvider<string> =>
  async () => {
    const rows = await piq
      .from(docs)
      .scan({ category })
      .select('params.slug', 'frontmatter.title')
      .exec();

    return rows.map((r) => ({ value: r.slug, label: r.title ?? r.slug }));
  };

Error Handling

A provider that throws surfaces the configured errorMessage. Return a sentinel option for the empty case:

const resilientProvider: OptionsProvider<string> = async () => {
  const rows = await piq
    .from(posts)
    .scan({})
    .select('params.slug', 'frontmatter.title')
    .exec();

  if (rows.length === 0) {
    return [{ value: '__empty__', label: 'No posts found', hint: 'Create a post first' }];
  }

  return rows.map((r) => ({ value: r.slug, label: r.title ?? r.slug }));
};

Pagination

The prompter provider resolves the full option set in one call; the UI adapter handles scrolling. If a data source is genuinely paginated, page through it inside the provider before returning, or use command-level resolve() on a context field, which additionally accepts a single page ({ options, nextCursor }) or an async iterator of pages. See defineCommand.