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 |