← Back to Home

How to Build Declarative Menus with State

Updated January 14, 2026
climenusdeclarativepromptsterminal-ui

Building Declarative Menus with State

Declarative menus separate menu structure from display logic, making CLI applications easier to maintain.

Menu Item Interface

interface MenuItem {
  name: string;           // Display name
  value: string;          // Internal value
  disabled?: boolean;     // Disable item
  description?: string;   // Help text
}

interface MenuSection {
  title?: string;         // Section header
  items: MenuItem[];
}

interface MenuConfig {
  type: 'select' | 'multi-select' | 'confirm';
  message: string;
  sections: MenuSection[];
}

Declarative Menu Builder

class MenuBuilder {
  private sections: MenuSection[] = [];
  private message: string = '';

  setTitle(title: string): this {
    this.message = title;
    return this;
  }

  addSection(title: string, items: MenuItem[]): this {
    this.sections.push({ title, items });
    return this;
  }

  addItems(items: MenuItem[]): this {
    this.sections.push({ items });
    return this;
  }

  build(): MenuConfig {
    return {
      type: 'select',
      message: this.message,
      sections: this.sections,
    };
  }
}

State-Aware Menu Service

interface AppState {
  hasSavedArticles: boolean;
  hasUnrenderedVideos: boolean;
  isYouTubeConfigured: boolean;
  selectedArticles: string[];
}

class MenuService {
  buildMainMenu(state: AppState): MenuConfig {
    const builder = new MenuBuilder()
      .setTitle('Main Menu')
      .addSection('Articles', [
        {
          name: 'Fetch & Browse Top Articles',
          value: 'fetch',
          description: 'Scrape Yahoo Japan top picks',
        },
        {
          name: 'View Saved Articles',
          value: 'view',
          disabled: !state.hasSavedArticles,
          description: state.hasSavedArticles
            ? `${state.selectedArticles.length} articles saved`
            : 'No articles saved yet',
        },
        {
          name: 'Generate Videos',
          value: 'generate',
          disabled: !state.hasUnrenderedVideos,
          description: 'Generate video compositions',
        },
      ])
      .addSection('YouTube', [
        {
          name: 'Upload to YouTube',
          value: 'upload',
          disabled: !state.isYouTubeConfigured,
          description: state.isYouTubeConfigured
            ? 'Upload rendered videos'
            : 'Configure YouTube first',
        },
        {
          name: 'YouTube Settings',
          value: 'youtube-settings',
          description: 'Configure OAuth credentials',
        },
      ]);

    return builder.build();
  }

  buildArticleMenu(article: FullArticle): MenuConfig {
    const hasScript = !!article.script;
    const hasAssets = article.script?.scenes?.every(s => s.assets);
    const hasComposition = !!article.compositionPath;
    const hasVideo = !!article.videoPath;

    return {
      type: 'select',
      message: `Article: ${article.title}`,
      sections: [
        {
          title: 'Generation',
          items: [
            {
              name: 'Generate Script',
              value: 'script',
              disabled: hasScript,
              description: hasScript ? 'Script generated' : 'Create video script',
            },
            {
              name: 'Download Assets',
              value: 'assets',
              disabled: !hasScript || hasAssets,
              description: hasAssets ? 'Assets downloaded' : 'Generate images and audio',
            },
            {
              name: 'Generate Composition',
              value: 'composition',
              disabled: !hasAssets || hasComposition,
              description: hasComposition ? 'Composition created' : 'Create Remotion composition',
            },
          ],
        },
        {
          title: 'Export',
          items: [
            {
              name: 'Render Video',
              value: 'render',
              disabled: !hasComposition || hasVideo,
              description: hasVideo ? 'Video rendered' : 'Render MP4 video',
            },
            {
              name: 'Upload to YouTube',
              value: 'upload',
              disabled: !hasVideo,
              description: 'Upload to YouTube channel',
            },
          ],
        },
      ],
    };
  }
}

Multi-Select Menu

interface MultiSelectMenuConfig {
  message: string;
  items: Array<{
    name: string;
    value: string;
    checked?: boolean;
    disabled?: boolean;
  }>;
}

function buildMultiSelectMenu(articles: FullArticle[]): MultiSelectMenuConfig {
  return {
    message: 'Select articles to process',
    items: articles.map(article => {
      const hasVideo = !!article.videoPath;

      return {
        name: `${article.title} ${
          hasVideo ? '(✓ video ready)' : ''
        } ${article.script ? '(✓ script)' : ''}`,
        value: article.id,
        checked: !hasVideo,  // Default select articles without videos
        disabled: hasVideo,  // Disable already processed
      };
    }),
  };
}

Rendering with Prompts

import prompts from 'prompts';

async function showMenu(menu: MenuConfig): Promise<string> {
  const choices = menu.sections.flatMap(section => {
    const items = section.items.map(item => ({
      title: item.name,
      value: item.value,
      disabled: item.disabled,
      description: item.description,
    }));

    return section.title ? [{ title: section.title, disabled: true } as any, ...items] : items;
  });

  const response = await prompts({
    type: 'select',
    name: 'value',
    message: menu.message,
    choices,
  });

  return response.value;
}

async function showMultiSelect(menu: MultiSelectMenuConfig): Promise<string[]> {
  const response = await prompts({
    type: 'multiselect',
    name: 'values',
    message: menu.message,
    choices: menu.items.map(item => ({
      title: item.name,
      value: item.value,
      checked: item.checked,
      disabled: item.disabled,
    })),
  });

  return response.values || [];
}

Menu State Machine

type MenuState =
  | 'main'
  | 'article-list'
  | 'article-details'
  | 'youtube-settings';

class MenuStateMachine {
  private currentState: MenuState = 'main';
  private selectedArticleId: string | null = null;

  navigateTo(state: MenuState, articleId?: string): void {
    this.currentState = state;
    this.selectedArticleId = articleId || null;
  }

  getCurrentMenu(state: AppState): MenuConfig {
    switch (this.currentState) {
      case 'main':
        return this.menuService.buildMainMenu(state);
      case 'article-list':
        return this.buildArticleListMenu(state);
      case 'article-details':
        return this.buildArticleDetailsMenu(this.selectedArticleId!);
      case 'youtube-settings':
        return this.buildYouTubeSettingsMenu();
      default:
        throw new Error(`Unknown state: ${this.currentState}`);
    }
  }
}

Dynamic Item Disabling

function buildProcessingMenu(articles: FullArticle[]): MenuConfig {
  const allHaveScripts = articles.every(a => a.script);
  const allHaveAssets = articles.every(a =>
    a.script?.scenes?.every(s => s.assets)
  );

  return {
    type: 'multi-select',
    message: 'Select processing steps',
    sections: [
      {
        title: 'Generation',
        items: [
          {
            name: 'Generate Scripts',
            value: 'scripts',
            disabled: allHaveScripts,
            description: allHaveScripts ? 'All scripts generated' : 'Create video scripts',
          },
          {
            name: 'Download Assets',
            value: 'assets',
            disabled: !allHaveScripts || allHaveAssets,
            description: allHaveAssets ? 'All assets downloaded' : 'Generate images and audio',
          },
        ],
      },
    ],
  };
}

Benefits of Declarative Menus

Aspect Imperative Declarative
Readability Logic mixed with UI Clear structure
Testing Hard to test Easy to assert menu structure
State handling Manual Automatic based on state
Maintenance Changes spread out Centralized configuration
Disabled items Manual checks Derived from state
Descriptions Inline Centralized