Skip to content

Menu

Search across your tools, shell functions, aliases, git aliases, forgit shortcuts, tmux keybindings, workflows, and Claude skills in one place, then see everything known about the one you pick. menu is a thin pointer: it federates your collections into one searchable index and, on selection, assembles the full picture from whichever sources have it. It stores no content of its own.

Quick Start

menu                    # Launcher: your areas + every custom tool you own
menu dashboard          # Everything outstanding across your apps, in lanes
menu keybind            # Search the federated index, pre-filtered to a term
menu find keybind       # Search, explicit
menu search keybind     # Same as menu find

Bare menu opens the launcher (see below). Passing a term goes straight to the federated search; type to filter and press Enter on any result to open its full view in a pager. From tmux: Ctrl-Space then m (opens menu in a popup at the current path).

The launcher

Bare menu — no arguments — opens a picker of your own areas and tools, so "what can I even run here" is one keystroke away. It lists menu's four areas (dashboard, find, review, labs) followed by every tool in the registry's custom-tools category, each previewed with its toolbox entry. Selecting an area enters it; selecting a tool prints its command so you can run it in your own shell. menu runs in its own process, so it prints rather than launches — uniformly, whether the tool is a binary or a shell function, so there is nothing to learn about which is which. This is the fix for forgetting a tool exists — packages, syncer, patterns, notes, and the rest are all right there. To skip straight to content search, give menu a term or use menu find / menu search.

What it searches

menu builds a live index over eight collections, each shown with a source tag so you know where a result lives:

Source Collection Where it comes from
[tool] tools registry $TOOLBOX_REGISTRY, else the registry under the XDG data dir
[func] shell functions ~/.local/shell/functions.sh + $PLATFORM.sh (#@name / #-->desc annotations)
[alias] shell aliases ~/.local/shell/aliases.sh + $PLATFORM.sh
[git] git aliases git config --get-regexp '^alias\.' (invoke as git <name>)
[forgit] forgit fzf-git shortcuts the forgit plugin (ga, gd, glo, …) — sourced after aliases.sh, so in no shell file menu reads
[tmux] tmux keybindings the tmux-commands card (~/.local/share/workflows/tmux-commands.md), its key/description tables parsed into one row per binding
[workflow] reference cards ~/.local/share/workflows/*.md (frontmatter tags)
[skill] Claude skills ~/.claude/skills/*/SKILL.md

Functions surface only if they carry the #@name / #-->desc annotation — the same convention toolbox funcs reads. That annotation is the opt-in: unannotated functions are internal helpers and stay hidden. Aliases are indexed by definition, with the preceding comment as the description.

Adding a new collection later is one more index function; menu never grows heavy because the depth always lives in the collections, not here.

Search is what you see

Each result's line carries its name, description or title, and tags — and that whole line is exactly what fuzzy matching runs over. Search is WYSIWYG: if a result surfaced, you can see the word that matched it, right there in the line. This is a deliberate constraint of the picker (fzf can only search the text it displays), turned into a feature.

The practical consequence: tags are the discovery contract. Because tags ride along in every line, a search finds everything carrying a matching tag, and a name or tag hit ranks above a looser match buried in a description. If something you expect doesn't surface, the fix is a better tag on that entry, not a change to menu.

Enter opens the full view

Finding a thing and understanding it are one motion. Pressing Enter assembles every lens that has content for the selected subject, in priority order, showing only the sections that exist:

Lens Fires when Answers
help the subject is one of your own tools and resolves on PATH live flags, as of right now
toolbox the subject is in the registry why you'd reach for it, curated examples
tldr a tldr page exists common real-world invocations
cheat a cheat sheet exists your saved snippets
workflow a card of the same name exists your multi-step reference
skill a skill of the same name exists the raw SKILL.md
function an annotated shell function of that name exists its definition, syntax-highlighted
alias an alias of that name is defined its expansion
git alias a git alias of that name is defined its expansion (invoke as git <name>)
forgit the name is a forgit plugin alias the forgit action it runs (fzf-interactive git)
tmux the name is a tmux keybinding the tmux-commands keybinding reference card

So selecting your own backmeup shows its live --help alongside its registry entry, while an external bat shows its registry entry with the tldr and cheat pages. The --help lens is limited to your own tools on purpose: running --help against an arbitrary external command is not safe, and externals are covered by tldr and cheat anyway.

The full view is piped through a pager (less -FRX): short subjects print inline, long composites scroll comfortably, and the output stays on screen after you quit so you can select and copy any command from it. Every lens also names its source — extracted functions and aliases carry the file path via bat --file-name, and the composite view prints a ↳ path breadcrumb — so you always know what you're looking at.

Where search answers "how do I do this", the dashboard answers "what should I be doing". It is one read-only glance across every app: open tasks, habits still due, what you're learning, what you're reading and what you're queued to read, maintenance that's come due, the next project items, and what's approaching.

One lane per app, never per layout. Books and articles are separate apps, so they are separate lanes — merging them into one "reading" lane meant neither of its counts described the pile above it. The shape of icb overview follows the app model for the same reason: it is the shared read surface for every renderer, so bending a section to suit one renderer's lanes would be the wrong trade. Consumers pin a schema version and report skew rather than half-reading a payload they don't understand.

menu dashboard                     # every lane, a few ranked rows each
menu dashboard --lane maintenance  # one lane, ten rows — repeatable
menu dashboard --json              # the lane model, for scripting

Lanes, not a merged list. Each lane is ordered by its own app and nothing ranks across them. A single ordering over tasks, books, and habits would have to invent a comparison that doesn't exist, so the dashboard doesn't try — you pick a lane by the time and energy you actually have.

Every row names something you could act on. Where a backend can only report structure — a track's current unit, a maintenance entry's register slug — the lane resolves it to the underlying resource or restates it in the entry's own words rather than spending a row on a label you can't open. The learning lane is the clearest case: learning overview answers which resource each track's current unit actually points at, so the dashboard lists titles you can open and notes which stream each one advances, instead of naming a unit you cannot sit down and do.

A row shows the handle you would type. Habits carry their id, because icb habits complete takes one and a name is not something you can act on from the keyboard. Only outstanding habits carry it — a completion record identifies the completion, not the habit, and a finished one needs no handle.

Rows are ranked and capped; a complete set is not. Most lanes show their most urgent few; lanes whose useful unit is bigger set their own cap (tasks is the list you pick from, and learning carries one row per stream). Habits are the exception entirely: every current habit is due every day, so the lane shows the whole set in columns with the finished ones ticked off, ordered so a habit keeps its place all day and checking one off never shuffles the rest.

A truncated lane says where to go, not how much it hid. The heading already reports the lane's real size, taken from the backend's own total rather than the rows it happened to build, so a remainder count added a number you could not act on. Each lane ends with the command that shows the rest. --json still carries every row the backend returned plus total, so a consumer applies its own cap rather than inheriting the terminal's.

One call per backend. Every lane's data comes from a backend that speaks --json, all queried concurrently:

Lane Backend Command
Tasks, Habits, Books, Articles, Projects, Upcoming icb icb overview --json
Learning learning learning overview --json
Maintenance menu-review, menu-labs menu-review list --json, menu-labs list --json

icb overview and learning overview are composites on their own side precisely so this stays one call each — the dashboard never fans out across an app's individual endpoints. It is also purely a renderer: which book counts as "next" and which habit is due today are decided by the app that owns that data, never re-derived here.

Degradation is per lane. A backend that is missing, timed out, unauthenticated, or too old costs exactly one lane, which collapses to a single line naming the reason; everything else still renders. That matters most on the work machine, where icb and learning may legitimately be absent but maintenance still works. Because icb reserves exit code 2 for usage errors, a rejected overview is reported as an out-of-date binary rather than a runtime failure. A lane with nothing in it shows ; empty is not a failure.

menu dashboard always exits 0 — it is a read-only glance, so degradation is displayed rather than raised. Anything consuming --json should check each lane's status field, not the exit code.

This is not forge brief. They share the plumbing and nothing else: forge brief is the dev briefing across repos that a coding session reads, while the dashboard is a human's glance across all apps. Neither should grow to cover the other's job — see ~/dev/vision.md.

Finding a tool once doesn't build retention; returning to it on a schedule does. menu review is the temporal half: a terminal-native cadence register for the things you mean to revisit or run periodically — a maintenance command, a skill, "relearn one neglected tool."

menu review               # what's due now, most overdue first
menu review list          # every registered item and its status
menu review done <id>     # mark an item done — advances its due date
menu review edit          # edit the register in $EDITOR

The register is deliberately two files, following the split between authored content you own and state the tool manages:

  • a register — declarative config you hand-edit: each item's description, cadence, an optional command to show, and an optional show: command to run (see the nudge below). menu review only ever reads it, so your comments and layout are never disturbed. It is owned by dotfiles and symlinked under the XDG data dir, so it is version-controlled and reaches every machine.
  • a state file — the last-done date per item, written by done. It lives under the XDG state dir; keeping it out of git avoids a commit on every done.

The due date is derived, never stored: next_due = last_done + cadence. Marking something done just stamps today, so there is no date to keep in sync and nothing to drift. A new item with no recorded done shows as never done and sorts to the top. Replication of the state across machines is arranged by the sync layer, not the tool; override either path with MENU_REVIEW_REGISTER / MENU_REVIEW_STATE.

This half is written in Python (apps/common/menu-review), which menu review delegates to — date arithmetic and JSON state are far cleaner there than in shell. The picker stays bash, because it is just fzf glue.

The startup nudge

menu review is pull — it only helps when you remember to run it. The nudge makes it push: at most once per interval (default 4h, set with menu review nudge-every <dur>), a shell surfaces what's due and stays silent when you are caught up. This replaces the old workflows motd random-card-on-startup — scheduled return to a topic you chose beats random exposure to one you didn't.

The gate lives in .zshrc: a cheap check of the marker file's age against the interval decides whether enough time has passed, and only then spawns the reviewer. Keeping the gate in shell means the Python script launches at most once per interval, not on every prompt. menu-review nudge is the renderer it calls — it prints due items or nothing, and does no throttling of its own. The marker and interval live in the synced menu state dir, so the schedule is shared across machines — one rolling nudge budget, not one per machine.

The nudge is a different density from menu review, not just a quieter one. menu review is a browse view you asked for, so it carries every field. The nudge is an interrupt you didn't, so each due item collapses to one line — its id, how overdue it is, and the command to run. Descriptions, tags, and cadences are browse-time information; at first shell you already know what your own register items mean. The roster is capped, and the overflow points at menu review rather than printing itself. Every line is clipped to the terminal rather than wrapped, because a row that wraps is two rows and the nudge quietly doubles on a narrow pane. nudge_header / nudge_row in menucore are the shared renderers, so menu review and menu labs nudge identically.

Live content (show:). A register item can carry a show: command whose output is run and inlined when that item is due in the nudge — distinct from command:, which is only displayed. This is what makes "relearn a neglected tool" concrete: the revisit-a-tool item sets show: toolbox remind, so when it comes due the nudge shows the specific registry tool you have not used in 90 days (toolbox remind picks it, weighted by neglect and round-robined so it cycles). The show: command runs only in the nudge, never in menu review / list, because it has side effects — toolbox remind advances its own history each call — and running it on every manual view would burn through the rotation. It is timeout-guarded so a slow command cannot wedge startup. This replaced the old standalone toolbox remind shell-startup block, which fired a passive tool card on every shell; folding it into the register makes it one actionable, cadenced line instead.

A show: command must emit at most a couple of lines, because it runs inside a nudge. This is the constraint that is easy to lose: a command that prints its own full listing turns the nudge back into a wall of text, and it does so silently, since nothing about editing that command brings you back to the register. Both current show: commands are bounded at their own source rather than trimmed here — menu labs has a one-line nudge form, and toolbox remind --brief caps its card — because rendering is the child's job and capturing its output would cost the colour.

Reviewing a tool's card builds recognition; using it builds retention. menu labs is the practice half: a small deck of hands-on Labs that walk you through a tool the way a senior engineer walks a junior — do this, expect that, here's why, here's the alternative — while you type along in a scratch directory.

menu labs                 # what's due to practice now
menu labs list            # every Lab and its schedule status
menu labs <id>            # open a Lab to read while you work in another pane
menu labs done <id>       # mark a Lab practiced — advances its schedule
menu labs new <name>      # scaffold a new Lab and open it in $EDITOR
menu labs flash [x]       # quick recall drill from your tool examples

A Lab is one markdown file. Labs live in ~/.local/share/menu-labs/*.md (authored in the dotfiles source at configs/common/.local/share/menu-labs/ and deployed by the symlink manager — the same convention as workflow cards). Each Lab teaches one tool or flow: a short copy-pasteable setup block that stages a throwaway directory, then numbered steps giving the command, the expected result, why it works, and alternatives. Frontmatter carries only tags (the same discovery vocabulary the picker uses) and an optional cadence; the title is the file's first # heading, exactly like a workflow card.

You drive the panes. The runner is deliberately minimal — it renders the Lab and tracks the schedule, nothing more. Open two tmux panes yourself: read the Lab in one (menu labs <id> pipes it through bat), copy its setup block into the other, and work through the steps at your own keyboard. There is no grading, no sandbox automation, no keystroke capture — the point is the reps, not a score.

Scheduling reuses the review model. A Lab with a cadence is scheduled exactly like a review item — next_due = last_done + cadence, derived not stored — with state in ~/dev/labs-state.json (Syncthing-synced; override the deck path with MENU_LABS_DIR and state with MENU_LABS_STATE). A Lab with no cadence is practice-on-demand and never shows as due. menu labs shows what's due; mark one done once you've worked through it.

Flashcards need no authoring. menu labs flash builds a recall deck straight from the tool registry's examples: it shows an example's description, you recall the command, then reveal and self-mark. Scope it to one tool or tag with menu labs flash <x>. This gives every tool something to drill from day one, before any Lab exists for it. Sessions stay short by design — a few minutes daily beats cramming.

Federation for a bare tool name. menu labs <tool> (when the argument isn't a Lab) shells to menu __show to assemble that tool's whole constellation across every lens — its registry entry, a same-named workflow card, aliases, functions — then points you at its flashcards. So even with no Lab written, asking to practice a tool surfaces everything you already have on it.

In the nudge. The review register carries a practice-a-lab item (show: menu labs --due), so the twice-daily startup nudge advertises when Labs come due, right beside the neglected-tool reminder — and stays silent when you're caught up. That form is a single line: a count plus a few sample ids. The deck comes due in clumps, and a startup nudge asking you to pick one of fifteen is a catalogue rather than a prompt — naming a few makes the choice concrete, and menu labs is still there for the rest.

Implementation

Location: apps/common/menu (picker, bash), plus three Python helpers — apps/common/menu-review (register), apps/common/menu-labs (Labs), and apps/common/menu-dashboard (the cross-app glance). All three share menucore — a small repo-root package (cadence parsing, atomic state, the color palette) imported via each script's resolved path, so no install step is needed and the register and Labs never drift apart on scheduling.

Dependencies: fzf (picker) and yq (registry) drive search and the launcher; the full view delegates to toolbox, workflows, tldr, cheat, and bat, and is paged through less; formatting.sh provides the shell styling. The register and Labs run as uv single-file scripts depending only on pyyaml; the dashboard declares no dependencies at all, since every backend it composes speaks --json and it parses nothing else.

The index is a three-column tab-separated stream — display, source, name — built by build_index. Two internal subcommands help with debugging and scripting: menu __index prints the raw index, and menu __show <name> renders a subject's full view non-interactively.

See Also

  • Toolbox - Browse and show details for any tool in the registry
  • Theme - Theme management
  • Notes - Note-taking