I take a lot of notes in markdown. Design docs, meeting notes, half-baked ideas, code review walkthroughs — all .md files scattered across a directory tree on disk. The two ways I'd been reading them were both bad:
- Editor preview panes (VS Code, Obsidian). Great rendering, but they pull me out of the terminal where I actually live.
cat,bat,less,glow. Stays in the terminal, but I either lose the rich rendering or I lose the tree — the ability to skim a folder, hop between files, and watch them update as I edit.
What I actually wanted was an nnn/yazi-style file picker on the left, a live-rendered markdown panel on the right, no mouse, no Electron, no context switch. So I built one.
mdmux is a Rust TUI that does exactly that. On a machine with cmux running you get cmux's native renderer; on any other machine — or if you pass --no-cmux — mdmux renders the markdown panel itself, inside its own ratatui surface. No external dependencies required.
mdmux /home/me/notes
┌ Files (12) ────────────────────────┬────────────────────────────────────────┐
│ 📝 README.md │ # endpoints │
│▾ 📁 docs │ │
│ ▾ 📁 api │ The API uses REST with JSON bodies. │
│ ► 📝 endpoints.md │ All requests require an `Auth` header.│
│ 📝 errors.md │ │
│ 📝 webhooks.md │ ## Routes │
│ 📝 getting-started.md │ │
│▾ 📁 notes │ `GET /v1/items` list all items │
│ 📝 2026-05-13-design.md │ `POST /v1/items` create an item │
│ 📝 random.md │ │
└────────────────────────────────────┴────────────────────────────────────────┘
open /home/me/notes/docs/api/endpoints.md [in-process]
↑/↓ move · enter open · / filter · j/k scroll preview · ? help · q quit
Two rendering modes, one tool
At startup, mdmux runs cmux ping. If it succeeds, you're in cmux mode: pressing Enter on a file shells out to cmux markdown open <path>, which splits the current pane and renders the file in cmux's native viewer with live reload. The key design choice here is replace, don't stack — selecting another file calls cmux close-surface --surface <prev> before opening the new one. No tab hoarding.
If cmux ping fails — or you pass --no-cmux — mdmux silently falls back to in-process mode: the TUI itself grows a second pane in a 40/60 split. The rendering is done by ratkit's markdown-preview widget, which gives headings, lists, blockquotes, inline emphasis, fenced code blocks with syntect-powered syntax highlighting, and pipe tables with header rows and column alignment — all for free. Live reload comes from a notify watcher — same experience as cmux, just rendered inside our own pane.
The status line tells you which mode you ended up in, so there's no guessing.
Why a separate tool, why not a cmux feature?
Three reasons, still the same as on day one:
- Boundaries. cmux is a terminal multiplexer. Knowing which markdown file to open next is a notes-app concern, not a multiplexer concern.
- Composition.
mdmux --listprints matched paths and exits — pipe it intofzf,xargs, your own scripts. The TUI is one front-end among many. - Testability. Every cmux call goes through a
CmuxClienttrait with a mock impl. The state machine is unit-tested without ever spawning a real cmux process.cargo testruns in <1s. In-process mode gets the same treatment — preview loading and watcher events are tested against the same state machine.
The original launch had an awkward story: "install cmux first, or you get a browse-only TUI with error overlays on Enter." That's the wrong gate for a Day-1 open-source release — it limits the audience to people who already use cmux. Adding the in-process renderer fixed that without compromising the cmux experience at all.
What I learned building it
A few things were surprisingly easy:
ignoreis a gift. Two function calls and I get ripgrep-grade.gitignorehandling, including nested ignores and global excludes.- ratkit does the heavy lifting. I expected to spend a week on a Markdown-to-ratatui renderer. The first launch was on
tui-markdown— one dependency and arender()call. Headings, fenced code blocks with syntax highlighting, blockquotes — all just worked. The only thing it stubbornly refused to do was tables, so v0.2.0 swapped it for ratkit's markdown-preview widget: same syntect-backed highlighting, plus pipe tables with column alignment and box-drawn borders sized to the pane. Cost: +15 KiB on the release binary, and a regression test that asserts table cells survive the renderer so a future swap can't silently drop the feature again. - Trait-based side effects kept me honest. Every time I was tempted to call
Command::new("cmux")directly, theCmuxClienttrait reminded me to route it through the wrapper. The state machine never knew cmux existed — which is exactly what made adding a second render mode painless.
A few were surprisingly hard:
- Watch the parent, not the file.
notifyon a single file misses saves from editors that write via rename-then-move (which is most of them, for atomicity). Watching the parent directory and filtering events by path is the fix. Easy to get wrong once, obvious in hindsight. - Context-sensitive key bindings.
jandkmean "move selection" in the tree, but "scroll preview" once a file is open. Getting the guards right — and making sure cmux users (who have no in-process preview) never see scroll behaviour — took a careful refactor. The final rule:j/kscroll only whenapp.preview.is_some(); otherwise they navigate the tree as usual. - Edge keys.
g gfor "jump to top" needs a pending-prefix state machine that times out and forgets, exactly like Vim. Most TUI key handlers I'd seen ignored this; getting it right took another refactor. - Surface lifecycle. "Quit while a panel is open" is the kind of case you forget until a user's pane is stuck open after
Ctrl+C. The fix was aDropimpl that closes any tracked surface, plus an explicitQto opt out.
Install
brew install nero408/tap/mdmux
# or
cargo install mdmux
macOS and Linux are supported. Rust ≥ 1.85 to build from source. cmux is optional — if it's not running, mdmux renders the preview pane itself.
Where it's headed
Short list of things I'm thinking about:
s— search across file contents (ripgrep-style), not just names.- Bookmarks — pin a directory to the top of the tree.
- A
--watchmode that re-walks on disk events instead of onr. - Open in editor —
eto open the selected file in$EDITORwithout leaving the TUI.
If any of those sound useful — or you have a use case I haven't thought of — open an issue. PRs welcome; cargo test should stay green and new behaviour gets a unit test against the CmuxClient trait.
Try it
brew install nero408/tap/mdmux
cd ~/notes
mdmux
That should be all it takes. cmux not required. If something breaks, tell me what broke.