simple-term-menu renders interactive keyboard-navigable terminal menus. pip install simple-term-menu. Basic: from simple_term_menu import TerminalMenu; menu = TerminalMenu(["Option A", "Option B", "Quit"]); idx = menu.show(); print(idx). Title: TerminalMenu(entries, title="Choose action\n"). Cursor: menu_cursor="▶ ". Style: menu_cursor_style=("fg_red","bold"). Hightlight: menu_highlight_style=("bg_blue","fg_white"). Multi-select: TerminalMenu(entries, multi_select=True, show_multi_select_hint=True) → menu.show() returns tuple. Multi accept: multi_select_select_on_accept=False — must press Space to select items, Enter to confirm. Search: show_search_hint=True → type to fuzzy-filter entries. Preview: preview_command="cat {}" or preview_command=fn(entry); preview_size=0.25. Keys: accept_keys=("enter","right"). quit_keys=("q","escape"). Cycle: cycle_cursor=True — wraps around at top/bottom. Shortcuts: prefix entries "[a] Option A". Clear screen: clear_screen=True. Raise: raise_error_on_interrupt=True → KeyboardInterrupt on Ctrl+C. Nested: return to parent menu by re-calling parent_menu.show(). Status bar: status_bar="Press / to search". TerminalMenu(entries, menu_entry_count_limit=10) for long lists. show_search_hint_text="type to search". Result: menu.chosen_menu_entry (str) and menu.chosen_menu_index (int). For multi: menu.chosen_menu_entries and menu.chosen_menu_indices. Claude Code generates simple-term-menu navigation systems, config editors, and interactive CLI selectors.
CLAUDE.md for simple-term-menu
## simple-term-menu Stack
- Version: simple-term-menu >= 1.6 | pip install simple-term-menu
- Basic: TerminalMenu(["A","B","C"]).show() → int index | None if cancelled
- Multi: TerminalMenu(entries, multi_select=True).show() → tuple of indices
- Search: show_search_hint=True — type to fuzzy-filter entries in real time
- Preview: preview_command="cat {}" or fn(entry) → right panel preview
- Result: menu.chosen_menu_entry (str) | menu.chosen_menu_entries (tuple)
- Nested: call show() in a loop, return None to go up a level
simple-term-menu Interactive CLI Pipeline
# app/menus.py — simple-term-menu navigation, multi-select, and nested menus
from __future__ import annotations
import math
import os
import sys
from typing import Any, Callable
from simple_term_menu import TerminalMenu
# ─────────────────────────────────────────────────────────────────────────────
# 1. Factory helpers
# ─────────────────────────────────────────────────────────────────────────────
def make_menu(
entries: list[str],
title: str = "",
multi_select: bool = False,
search: bool = False,
preview_fn: Callable[[str], str] | None = None,
preview_command: str | None = None,
cursor: str = "▶ ",
cursor_style: tuple[str, ...] = ("fg_cyan", "bold"),
highlight_style: tuple[str, ...] = ("bg_blue", "fg_white"),
cycle: bool = True,
clear_screen: bool = False,
status_bar: str = "",
) -> TerminalMenu:
"""
Create a TerminalMenu with consistent defaults.
preview_fn: callable(entry) → str used as preview text.
preview_command: shell command string — "{}" replaced with entry.
"""
kwargs: dict[str, Any] = {
"menu_cursor": cursor,
"menu_cursor_style": cursor_style,
"menu_highlight_style": highlight_style,
"cycle_cursor": cycle,
"clear_screen": clear_screen,
"raise_error_on_interrupt": True,
}
if title:
kwargs["title"] = title + "\n"
if multi_select:
kwargs["multi_select"] = True
kwargs["show_multi_select_hint"] = True
kwargs["multi_select_select_on_accept"] = False
if search:
kwargs["show_search_hint"] = True
if preview_fn:
kwargs["preview_command"] = preview_fn
kwargs["preview_size"] = 0.35
elif preview_command:
kwargs["preview_command"] = preview_command
kwargs["preview_size"] = 0.35
if status_bar:
kwargs["status_bar"] = status_bar
return TerminalMenu(entries, **kwargs)
def choose(
entries: list[str],
title: str = "Choose an option",
search: bool = False,
) -> str | None:
"""
Show a menu and return the chosen entry string, or None if cancelled.
"""
if not sys.stdout.isatty():
return entries[0] if entries else None
try:
menu = make_menu(entries, title=title, search=search)
idx = menu.show()
return menu.chosen_menu_entry if idx is not None else None
except KeyboardInterrupt:
return None
def choose_multi(
entries: list[str],
title: str = "Select items (Space to toggle, Enter to confirm)",
search: bool = False,
) -> list[str]:
"""
Show a multi-select menu. Returns list of chosen entry strings.
"""
if not sys.stdout.isatty():
return entries
try:
menu = make_menu(entries, title=title, multi_select=True, search=search)
menu.show()
return list(menu.chosen_menu_entries or [])
except KeyboardInterrupt:
return []
# ─────────────────────────────────────────────────────────────────────────────
# 2. Navigation helpers
# ─────────────────────────────────────────────────────────────────────────────
def confirm(
prompt: str = "Confirm?",
default: bool = True,
) -> bool:
"""Simple Yes / No confirmation menu."""
entries = ["Yes", "No"] if default else ["No", "Yes"]
result = choose(entries, title=prompt)
return result == "Yes"
def choose_from_dict(
options: dict[str, Any],
title: str = "Choose",
) -> tuple[str, Any] | tuple[None, None]:
"""
Show a menu with dict keys as labels.
Returns (key, value) of chosen entry, or (None, None) on cancel.
"""
keys = list(options.keys())
result = choose(keys, title=title)
if result is None:
return None, None
return result, options[result]
def paginate(
entries: list[str],
page_size: int = 10,
title: str = "Select",
) -> str | None:
"""
Show a paginated menu — next/previous page entries added automatically.
"""
page = 0
n_pages = max(1, math.ceil(len(entries) / page_size))
while True:
start = page * page_size
chunk = entries[start: start + page_size]
nav = []
if page > 0:
nav.append("← Previous page")
if page < n_pages - 1:
nav.append("→ Next page")
nav.append("✕ Cancel")
all_entries = chunk + nav
hdr = f"{title} (page {page+1}/{n_pages})"
result = choose(all_entries, title=hdr)
if result == "← Previous page":
page -= 1
elif result == "→ Next page":
page += 1
elif result == "✕ Cancel" or result is None:
return None
else:
return result
# ─────────────────────────────────────────────────────────────────────────────
# 3. Nested menu system
# ─────────────────────────────────────────────────────────────────────────────
class MenuNode:
"""
A node in a recursive menu tree.
Usage:
root = MenuNode("Main Menu", {
"Deploy": MenuNode("Deploy", {
"Production": lambda: deploy("prod"),
"Staging": lambda: deploy("staging"),
}),
"Config": MenuNode("Config", {
"Edit .env": edit_env,
"Reset": reset_config,
}),
"Exit": None,
})
root.run()
"""
def __init__(
self,
title: str,
children: dict[str, "MenuNode | Callable | None"],
back_label: str = "← Back",
search: bool = False,
):
self.title = title
self.children = children
self.back_label = back_label
self.search = search
def run(self, depth: int = 0) -> bool:
"""
Show the menu. Returns False if user chose Back/Exit.
"""
keys = list(self.children.keys()) + [self.back_label]
while True:
try:
menu = make_menu(
keys,
title=self.title,
search=self.search,
)
idx = menu.show()
if idx is None:
return False
label = menu.chosen_menu_entry
if label == self.back_label:
return False
child = self.children[label]
if child is None:
return False
elif isinstance(child, MenuNode):
child.run(depth=depth + 1)
elif callable(child):
child()
except KeyboardInterrupt:
return False
# ─────────────────────────────────────────────────────────────────────────────
# 4. File picker
# ─────────────────────────────────────────────────────────────────────────────
def pick_file(
directory: str = ".",
pattern: str = "*",
title: str = "Select a file",
preview: bool = True,
) -> str | None:
"""
Browse files in a directory with optional preview.
pattern: glob pattern to filter files.
"""
import glob as _glob
import pathlib
path = pathlib.Path(directory).resolve()
files = sorted(str(f) for f in path.glob(pattern) if f.is_file())
if not files:
return None
short_names = [os.path.relpath(f, directory) for f in files]
name_to_path = dict(zip(short_names, files))
preview_fn = None
if preview:
def preview_fn(entry: str) -> str:
full = name_to_path.get(entry, "")
try:
with open(full, encoding="utf-8", errors="replace") as fh:
return fh.read(2000)
except Exception:
return "(binary or unreadable)"
result = choose(short_names, title=title, search=True) if not preview else None
if preview:
try:
menu = make_menu(
short_names,
title=title,
search=True,
preview_fn=preview_fn,
)
idx = menu.show()
result = menu.chosen_menu_entry if idx is not None else None
except KeyboardInterrupt:
result = None
return name_to_path.get(result) if result else None
# ─────────────────────────────────────────────────────────────────────────────
# 5. Config editor
# ─────────────────────────────────────────────────────────────────────────────
def edit_config(
config: dict[str, Any],
title: str = "Edit configuration",
editor: str | None = None,
) -> dict[str, Any]:
"""
Interactive config editor: pick a key, then set a new value.
Returns the (possibly modified) config dict.
"""
import json
import subprocess
import tempfile
while True:
entries = [f"{k} = {v!r}" for k, v in config.items()] + ["← Done"]
result = choose(entries, title=title)
if result is None or result == "← Done":
break
key = result.split(" = ")[0]
old_val = config[key]
val_type = type(old_val)
if isinstance(old_val, (dict, list)):
# Open in editor for complex types
ed = editor or os.environ.get("EDITOR", "nano")
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as tmp:
json.dump(old_val, tmp, indent=2)
tmp_path = tmp.name
subprocess.run([ed, tmp_path])
try:
with open(tmp_path) as fh:
config[key] = json.load(fh)
except Exception:
pass
os.unlink(tmp_path)
else:
# Inline: show current + prompt for new value
raw = input(f" {key} [{old_val!r}] > ").strip()
if raw:
try:
config[key] = val_type(raw)
except Exception:
config[key] = raw
return config
# ─────────────────────────────────────────────────────────────────────────────
# Demo
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
if not sys.stdout.isatty():
print("Demo requires a TTY — run in a real terminal.")
sys.exit(0)
print("=== Single select ===")
action = choose(
["Deploy to production", "Deploy to staging", "Run tests", "Exit"],
title="What do you want to do?",
)
print(f" Chosen: {action!r}")
print("\n=== Multi-select ===")
services = choose_multi(
["api", "worker", "scheduler", "redis", "postgres"],
title="Select services to restart",
)
print(f" Selected: {services}")
print("\n=== Confirmation ===")
ok = confirm("Deploy to production?")
print(f" Confirmed: {ok}")
print("\n=== Nested menu ===")
def _action(name: str):
def _fn():
print(f" → Executed: {name}")
return _fn
root = MenuNode("Main Menu", {
"Build": MenuNode("Build", {
"Docker image": _action("docker build"),
"Assets": _action("npm run build"),
}),
"Deploy": MenuNode("Deploy", {
"Production": _action("deploy prod"),
"Staging": _action("deploy staging"),
}),
"Exit": None,
})
root.run()
For the questionary alternative — questionary offers a richer set of prompt types (text input, password, checkbox, autocomplete, path, and select) rendered with arrow-key navigation and styled with prompt_toolkit; simple-term-menu is narrower in scope — list selection only — but adds file preview panels with preview_command and multi-select checkboxes, which questionary’s select doesn’t include natively; use simple-term-menu when you need file pickers and preview panels, questionary when you need mixed prompt types in a single flow. For the pick alternative — pick is a zero-dependency single-module selector using curses (or Windows console); simple-term-menu is larger but adds multi-select, search filtering, preview panels, and custom keyboard shortcuts that pick doesn’t support. The Claude Skills 360 bundle includes simple-term-menu skill sets covering TerminalMenu() constructor, make_menu() factory, choose() and choose_multi() helpers, confirm() yes/no dialog, choose_from_dict() dict picker, paginate() paged navigation, MenuNode recursive nested menus, pick_file() file browser with preview, edit_config() interactive config editor, multi_select with Space+Enter workflow, show_search_hint fuzzy filtering, and preview_command/preview_fn panel. Start with the free tier to try interactive terminal menu code generation.