A fast terminal Markdown viewer for GFM and Azure DevOps Wiki Markdown, built in Rust.
mdvu renders one Markdown file — or stdin — as styled terminal text, including
Mermaid diagrams drawn as Unicode or ASCII art. It is built for reviewing a
document a coding agent just changed, without leaving the terminal and without a
browser, Node.js or any external command.
- One document at a time. No file tree, no workspace, no navigation history. You point it at a file, read it, and quit.
- Azure DevOps Wiki as a first-class flavor.
[[_TOC_]],::: mermaid,<details>, work item references and attachments are understood, not shown as stray syntax. - Mermaid without a runtime. Diagrams are parsed and drawn by
mermanas terminal text. There is no Chromium and nommdc. - Local images, where the terminal can show them. A PNG, JPEG, GIF or WebP next to the document is drawn inline with the kitty or iTerm2 graphics protocol. No decoder, no network, no external command.
- Japanese text is not an afterthought. Wrapping uses Unicode display width
and grapheme boundaries, with JIS X 4051 kinsoku so
。,ーand a small kana never start a line and「never ends one. - Composes with your tools.
fzf,fd,rgand Git stay external.
mdvuis more review-oriented than Glow, more Markdown-specific than mcat, and CLI-native rather than editor-native like md-render.nvim.
Prebuilt binaries for Linux and macOS are attached to each release.
tag=v0.1.0
target=aarch64-apple-darwin # or x86_64-apple-darwin, {x86_64,aarch64}-unknown-linux-gnu
curl -fsSLO "https://github.com/halkn/mdvu/releases/download/$tag/mdvu-${tag#v}-$target.tar.gz"
tar xzf "mdvu-${tag#v}-$target.tar.gz"
install "mdvu-${tag#v}-$target/mdvu" ~/.local/bin/Each release also carries a SHA256SUMS file. mdvu is not published to
crates.io, and Windows binaries are not distributed.
Requires a stable Rust toolchain (2024 edition).
git clone https://github.com/halkn/mdvu
cd mdvu
cargo install --path .mdvu [OPTIONS] [FILE]
FILE is a Markdown file, - for stdin, or omitted when stdin is a pipe.
mdvu README.md # interactive pager
mdvu docs/architecture.md --line 143 # open near a source line
mdvu --watch notes.md # follow the file while an agent edits it
git show HEAD:docs/design.md | mdvu -
mdvu --no-pager --plain doc.md # unstyled text to stdout| Option | Description |
|---|---|
-p, --pager |
Force the interactive pager |
--no-pager |
Render to stdout without entering the alternate screen |
-w, --width <COLUMNS> |
Override the rendering width |
-l, --line <LINE> |
Open near the given 1-based source line |
--flavor <FLAVOR> |
gfm or azure-devops (default: azure-devops) |
--mermaid <MODE> |
unicode, ascii, source, off (default: unicode) |
--theme <THEME> |
auto, dark, light (default: auto) |
--color <WHEN> |
auto, always, never (default: auto) |
--hyperlinks <WHEN> |
OSC 8 links in stdout output: auto, always, never (default: auto) |
--highlight <WHEN> |
Syntax highlighting for code blocks: auto, never (default: auto) |
--images <WHEN> |
Inline images: auto, kitty, iterm2, never (default: auto) |
--icons <SET> |
Glyphs for alerts, code fences and placeholders: unicode, nerd (default: unicode) |
--watch |
Re-render when the file changes on disk (pager only) |
--plain |
Alias for --color never |
Without --pager or --no-pager, mdvu opens the pager when stdout is a
terminal and writes to stdout otherwise. --color auto honours NO_COLOR and
disables ANSI when stdout is not a terminal. --theme auto uses the COLORFGBG
hint when present and falls back to dark; it never issues a blocking terminal
query.
--hyperlinks marks http and https link labels as OSC 8 terminal
hyperlinks, so a supporting terminal can open them. Other destinations —
relative paths, attachments, anchors and other schemes — are shown but never
linked, and mdvu itself never opens anything. Hyperlinks are escape sequences,
so --plain and --color never suppress them. auto emits them only when
stdout is a terminal; use always for a captured preview such as fzf.
Underline means "the destination is a real URL". Link labels are coloured, but
only http and https targets are underlined, so they stand out from a
relative path, a #123 or an @alias styled the same way. The mark is the same
in the pager and on stdout; where hyperlinks are emitted, an underlined label is
also the one the terminal can open.
--images draws local images with a terminal graphics protocol; see
Images below. --icons nerd swaps parts of the chrome for Nerd Font
glyphs; see Icons.
--watch follows the file while something else edits it — a coding agent, or
your editor in another window — and re-renders on every save. The reading
position is kept: the source line at the top of the viewport stays there. A save
that is briefly unreadable is reported in the status bar and leaves the previous
rendering on screen. Watching needs a file and the pager, so it cannot be
combined with stdin or --no-pager.
Exit codes: 0 success, 1 a fatal input, decode, terminal or output error,
2 a usage error. A Mermaid diagram that fails to render is never fatal.
mdvu reads ~/.config/mdvu/config.toml if it exists. It only sets defaults
for the flags above; a flag given on the command line always wins. MDVU_CONFIG
overrides the path, and setting it to an empty string disables the file.
flavor = "gfm"
mermaid = "ascii"
theme = "dark"
color = "auto"
hyperlinks = "always"
highlight = "auto"
images = "auto"
icons = "unicode"
width = 100
watch = trueAn unknown key or an invalid value is a usage error rather than something
silently ignored. watch = true is skipped where it cannot apply, such as when
reading stdin.
| Key | Action |
|---|---|
j, Down |
Down one line |
k, Up |
Up one line |
Ctrl-d / Ctrl-u |
Half screen down / up |
Space, PageDown |
One screen down |
b, PageUp |
One screen up |
g, Home |
Top of the document |
G, End |
End of the document |
h, Left / l, Right |
Scroll horizontally |
0 |
Reset horizontal scroll |
/ |
Search as you type, Enter to confirm, Esc to cancel |
n / N |
Next / previous match |
t |
Heading list, j / k to select, Enter to jump, Esc to close |
? |
Key list, j / k to scroll, Esc to close |
q, Esc |
Quit |
Search runs over the rendered text, highlights every match on screen and cycles
with n and N, saying wrapped when it passes either end. The match being
visited is coloured, the rest are plain reversed, so which one the viewport
moved to is visible without counting. The status bar
shows which match is current, as (3/12). An empty query keeps the previous
one. Case follows the query: a query in lower case matches any case, and one
capital makes the whole query case-sensitive.
The document follows the query as it is typed: every keystroke jumps to the
first match at or after where / was pressed, so a query can be judged before
confirming it. Esc puts back both the previous search and the reading
position; Enter keeps what is on screen. The prompt takes the readline keys —
Ctrl-a / Ctrl-e, Ctrl-b / Ctrl-f, arrows and Home / End to move,
Ctrl-w, Ctrl-u, Ctrl-k, Ctrl-d and Backspace to delete.
? opens the same key list inside the pager, so the bindings are readable
without leaving the document. The list in src/pager/help.rs is the source of
truth for it and for the table above.
t opens a list of the document's headings, preselecting the section on screen.
It works in both flavors and is independent of [[_TOC_]].
Resizing re-runs layout and keeps the source line that was at the top of the
viewport.
fd --type f --extension md |
fzf --preview 'mdvu --no-pager --color always --width "$FZF_PREVIEW_COLUMNS" {}'--color always is required: fzf captures the preview, so auto would
correctly decide the output is not a terminal and drop the styling.
git diff --name-only --diff-filter=ACMR -- '*.md' |
fzf --preview 'mdvu --no-pager --color always --width "$FZF_PREVIEW_COLUMNS" {}'mdvu renders the document as it now stands. Reading the diff itself is the job
of git diff, delta or a dedicated hunk tool — mdvu deliberately has no Git
integration.
Rendered: ATX headings, paragraphs, bold, italic, strikethrough, inline code, fenced and indented code blocks, ordered and unordered lists, nested lists, task lists, block quotes, nested quotes, alerts, horizontal rules, GFM tables, links, autolinks, images, footnotes, and hard and soft breaks. An image standing alone in its paragraph is drawn inline where the terminal supports it, and is a text placeholder everywhere else; see Images.
A quote that opens with [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] or
[!CAUTION] is drawn as a labelled box in a colour matching its kind. The label
is a word, so no font beyond the box drawing characters is assumed and the kind
is still readable under --plain; --icons nerd puts a glyph in front of it.
Alerts work in both flavors — Azure DevOps Wiki uses the same syntax — and an
unrecognised kind such as [!FOO] stays an ordinary quote with its marker
intact.
Bullets change with nesting depth, cycling •, ◦ and ▪.
Tables get column widths from intrinsic minimum and preferred widths, measured in display columns. When even the minimum widths do not fit, the table becomes a vertical list rather than a broken grid. Cell contents are never silently truncated.
Fenced code blocks with a language are syntax highlighted. Tokens are classified
by syntect and coloured from the same 16-colour
theme as the rest of the document, so both themes and every terminal work the
same way. An unknown language falls back to a uniform colour, and --plain
switches highlighting off entirely. Code blocks are not re-wrapped; scroll them
horizontally instead. Tabs expand to four-column tab stops.
Raw HTML is kept as literal text. Nothing in a document is ever executed: no JavaScript, no iframes, no network requests, no subprocesses.
Active under the default --flavor azure-devops.
| Syntax | Rendering |
|---|---|
[[_TOC_]] |
Table of contents built from the ATX headings |
Second [[_TOC_]] |
Shown as ignored, matching Azure DevOps |
[[_TOSP_]] |
Child pages unavailable in single-file mode |
::: mermaid |
Rendered diagram |
```mermaid |
Rendered diagram |
<details><summary> |
Expanded, with a border |
#123 |
Styled work item reference, no API lookup |
@alias |
Styled mention, no identity lookup |
<br/> in a table cell |
Line break inside the cell |
.attachments/... |
Image or attachment placeholder with its path |
::: video |
Unsupported media placeholder |
::: query-table |
Query placeholder, with the query id when present |
$...$, $$...$$ |
Shown as math source; KaTeX is not evaluated |
Unknown ::: block |
Placeholder that keeps the original body |
The [[_TOC_]] macro is case-sensitive, only the first occurrence expands, and
only # headings are collected. Azure syntax inside a code fence stays literal.
An unterminated ::: container is reported and its body is preserved.
Under --flavor gfm all of the above is disabled, and Azure macros are treated
as ordinary Markdown text.
mdvu renders Mermaid syntax to Unicode or ASCII terminal text with merman.
Diagrams are rendered once, before layout, so the frame loop never calls the
renderer.
--mermaid unicode(default) uses box drawing characters.--mermaid asciirestricts output to 7-bit ASCII.--mermaid sourceshows the Mermaid source in a labelled block.--mermaid offshows a one-line marker.
graph, sequenceDiagram, classDiagram and erDiagram render as diagrams.
Other families, including stateDiagram-v2, fall back to their source with a
note. A diagram that fails to parse falls back to its source with a short,
normalised error; the rest of the document still renders and the process still
exits 0.
Under the Azure flavor, mdvu warns about four documented Azure DevOps
incompatibilities: the flowchart root keyword, long arrows such as ---->,
Font Awesome icons, and HTML tags inside labels. A flagged diagram still renders.
An image that is the whole of its paragraph is drawn inline when the terminal
supports it. Everything else about it stays as before: an image among words
keeps its [image: ...] placeholder, because a picture there would need a
multi-row box inside a wrapped line.
The file's own bytes are handed to the terminal, which decodes them, so PNG,
JPEG, GIF and WebP work and nothing is decoded inside mdvu. SVG is not drawn:
no terminal renders it.
--images auto reads the environment only — TERM, TERM_PROGRAM,
KITTY_WINDOW_ID, KONSOLE_VERSION — and never asks the terminal what it
supports, since that would mean writing to the tty and waiting for an answer.
kitty, Ghostty, WezTerm and Konsole get the kitty protocol; iTerm2 gets its own.
Inside tmux, auto stays off, because passthrough depends on the outer
terminal and the tmux version; --images kitty forces it. Images are escape
sequences, so --plain, --color never and NO_COLOR suppress them, and
auto requires stdout to be a terminal.
Which files may be read is deliberately narrow. mdvu opens nothing else in a
document: links, attachments and other destinations are shown, never followed.
An image is drawn only when all of the following hold, and otherwise keeps its
placeholder without an error:
- the document came from a file, so there is a directory to resolve against
- the destination is a local path with no URL scheme;
http,httpsanddataare never fetched, andmdvumakes no network requests - the resolved path stays inside the content root, after both sides are
canonicalised, so
../leaving it and a symlink pointing outside are both refused. The content root is the nearest ancestor of the document holding a.gitentry, and the document's own directory when there is none, so a wiki page reaches the.attachmentsdirectory at the repository root. A destination starting with/is read from the content root, the way Azure DevOps and GitHub resolve it, never from the filesystem root - the extension is
png,jpg,jpeg,giforwebp, and the file's leading bytes agree with it - the file is at most 10 MiB
An image is scaled to fit the text width and capped at 20 rows, keeping its aspect ratio. In the pager it is drawn only while it fits on screen whole: half a picture over the status bar is worse than none, and neither protocol can crop a placement without sending it again.
--icons nerd draws part of the chrome with Nerd Font
glyphs: a glyph before an alert label, before a code fence's language, and in
place of the [image: ...] and [attachment: ...] words. Headings keep their
# markers and lists keep •, ◦ and ▪; the level and the depth are the
information there, and a missing glyph would take it away.
The default is unicode, which renders exactly what mdvu rendered before this
option existed. Nothing is auto-detected: whether the terminal's font carries
these glyphs cannot be read from the environment, and asking the terminal would
mean writing to the tty and waiting for an answer, which mdvu never does. Turn
it on per run with --icons nerd, or once with icons = "nerd" in the
configuration file.
Glyphs are ordinary characters rather than escape sequences, so unlike images
and hyperlinks they are unaffected by --plain, --color never and NO_COLOR.
A terminal without a Nerd Font shows tofu in their place; the columns still line
up, because each glyph is measured as one display column.
| Tool | Focus | How mdvu differs |
|---|---|---|
| Glow | General Markdown reader with a file browser | mdvu is review-oriented, single-file, source-line aware and Azure DevOps aware |
| mcat | Many file formats in the terminal | mdvu is Markdown only; no PDF, DOCX, HTML, image or video input, and images are drawn only as part of a document |
| md-render.nvim | High quality Markdown inside Neovim | mdvu is a standalone CLI with no editor dependency |
| DocSail | Markdown workspace viewer with a file tree | mdvu shows one file or stdin and has no workspace navigation |
- Not a full GFM browser renderer and not pixel-identical to Azure DevOps.
- Not fully Mermaid.js compatible.
mermancovers a subset of diagram families, and its text layout measures labels by character count, so diagrams with Japanese labels can have misaligned borders even though the labels are correct. - The Azure DevOps compatibility check covers four known rules only. It is not a validator; a clean run does not mean Azure DevOps will accept the diagram.
- Inline images need kitty or iTerm2 graphics. Sixel is not implemented, so
foot, xterm and Windows Terminal show placeholders. Detection is by
environment variable, so an unlisted terminal needs
--imagesnaming the protocol. - An image is drawn only when its paragraph holds nothing else, and only from the content root: the repository the document sits in, or its own directory outside one. SVG is never drawn.
--icons nerdassumes each glyph occupies one column, which is whatmdvumeasures and what a Nerd Font Mono variant draws. A terminal that draws them two columns wide shifts the rest of that line by one.- Kinsoku covers the JIS X 4051 CJK and halfwidth katakana classes. ASCII punctuation is excluded, and there is no phrase-level segmentation, so a line can still break in the middle of a Japanese word.
- Kinsoku wins over
--width. A run with no legal break point inside it is kept whole and its line runs past the requested width rather than breaking where kinsoku forbids. This needs a very narrow width or a long run of punctuation to happen, but a captured preview such asfzfwill show the longer line. - OSC 8 hyperlinks are emitted by the stdout backend only. In the pager a URL is
underlined but not clickable:
ratatuicells carry no hyperlink attribute. --watchfollows one file. A document that includes others is not tracked, becausemdvuhas no concept of includes.mdvunever opens a link or runs an external command. Clicking is the terminal's job.- Syntax highlighting covers the languages shipped with
syntectand classifies tokens into seven roles; it is not an editor-grade highlighter. - No mouse support, no user-defined themes and no configurable key bindings.
- Linux and macOS are the primary targets. Windows is built and unit-tested in CI, but the pager has not been verified interactively on a Windows terminal.
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo tree -dGolden rendering tests drive the binary end to end and compare snapshots with
insta. Review changes with cargo insta review, or
regenerate with INSTA_UPDATE=always cargo test --test render.
Prebuilt binaries are published from .github/workflows/release.yml on a v*
tag; the tag must match the version in Cargo.toml.
Per-module design decisions — why kinsoku is applied while chunks are built, why
Mermaid renders once before layout, why --watch watches the parent directory —
live in .claude/rules/, next to the code they constrain.
MIT. See LICENSE.