Summary
mdriver has no built-in paging. Long documents dump straight to stdout and you rely on your terminal's scrollback. The README (README.md:101) and --help (src/main.rs:43) suggest piping to a pager yourself:
mdriver --color=always README.md | less -R
...but that suggestion has three sharp edges, and one of them (images) cannot be fixed on the pager side at all. This issue proposes adding a --pager mode, and the image-protocol change needed to make paging actually work.
Current behavior and why the pipe-to-less advice is lossy
1. Colors are off by default when piped
ColorMode::Auto (src/main.rs) checks io::stdout().is_terminal(). Piping to a pager means no tty, so mdriver falls into passthrough mode and acts like cat. You must remember --color=always every time.
2. Width is computed wrong when piped
default_width() (src/lib.rs:92) is min(terminal_width, 80), and main.rs subtracts MDRIVER_PADDING from it. When stdout is a pipe this still works by luck on macOS (term_size reads the tty via stderr/stdin), but it is not something to rely on, and the 80-column cap means you get 80-col output inside a 200-col pager. Observed:
$ mdriver --color=always w.md | cat | awk '{print length}'
74
78
vs. --width 40 correctly producing 38/37/39/40. So a pager mode should pass the real terminal width explicitly.
3. Images break, and no pager can fix it
This is the important one. render_kitty_image (src/lib.rs:2713) emits:
"\x1b_Gf=100,a=T,c={},m={};{}\x1b\\"
Two problems:
(a) less won't relay APC. Per man less, -R passes through only ESC [ ... m (SGR) and OSC 8 hyperlinks in raw form. Kitty graphics are APC (ESC _ G ... ESC \), so under -R they get caret-escaped into visible garbage. -r does pass them, but then (quoting the man page) "less cannot keep track of the actual appearance of the screen... various display problems may result, such as long lines being split in the wrong place." Not viable for real paging.
(b) a=T is fundamentally unpageable. a=T means "transmit and display at the current cursor position." The image is placed by the terminal at wherever the cursor happens to be when the escape arrives, and it is not part of the text grid. So even a hypothetical pager that relayed the bytes byte-for-byte would still be wrong: scrolling doesn't move the image, because the terminal never associated it with a text line.
Survey of pagers — none of them solve this as-is:
| pager |
verdict |
less |
SGR + OSC 8 only under -R; -r relays APC but breaks screen tracking |
bat |
shells out to less, so identical limits |
moar |
does not support graphics sequences |
ov |
has an image feature, but it decodes image files itself; does not relay arbitrary APC from stdin |
most, more |
no |
Proposed fix
Part 1: Unicode placeholder image mode (the enabling change)
Kitty's graphics protocol has a mode built for exactly this: Unicode placeholders. You transmit with a=T,U=1,i=<id> and then emit a rectangle of U+10EEEE placeholder characters, with the image id encoded in combining diacritics. Those placeholders are real cells in the text grid, so they scroll, wrap, and are tracked correctly by any program that just passes the bytes along.
This is exactly what kitten icat --unicode-placeholder is for; its own help text says it is "useful to display images from within full screen terminal programs that do not understand the kitty graphics protocol such as multiplexers or editors."
Nothing like this exists in the codebase today — rg -n 'placeholder|U=1|10EEEE|tmux|passthrough' src/ returns zero hits.
Implementation notes:
- Add a variant to
ImageProtocol (src/lib.rs:58), e.g. KittyPlaceholder, wired through render_image (src/lib.rs:2529) and render_kitty_image (src/lib.rs:2713).
- Placeholders require knowing rows up front. Today we send only
c=<cols> and let kitty derive rows from aspect ratio (see the comment at src/lib.rs:2734). We already have real pixel dimensions at the resize step (src/lib.rs:2583-2601), so compute r from img.height() using the same PIXELS_PER_COLUMN-style assumption (will need a pixels-per-row constant; note the existing 9.0 px/col figure is already a rough guess).
- Assign a stable per-image id (
i=), incrementing per document. Guard against collisions with other programs by starting from a high-ish base.
- Emit
r lines of c copies of U+10EEEE, first cell carrying the row/column/id diacritics per the spec.
- Byte cost: the placeholder block is plain text, so it interacts sanely with
--width and --padding (apply_padding in main.rs will pad it like any other line, which is what we want).
- Also a nice side effect: this makes images work under tmux.
Part 2: --pager flag
Add a --pager / --no-pager flag plus $MDRIVER_PAGER (falling back to $PAGER, default less -RFX --mouse).
Behavior when paging is active:
- Spawn the pager and stream mdriver's output into its stdin. Streaming matters here and fits the project philosophy —
less starts displaying as soon as it has a screenful, so the incremental-emission design is preserved end to end.
- Force color on (we know we're feeding a pager on a tty, so the
is_terminal check must be bypassed rather than consulted).
- Pass the real terminal width to the parser, not
min(width, 80), since the pager occupies the full terminal.
- Auto-upgrade
--images kitty to placeholder mode, since the pager can't handle a=T. Possibly warn (on stderr) if the user explicitly asked for non-placeholder kitty images together with a pager.
- Handle the pager exiting first: quitting
less with q mid-stream should exit 0 silently, not print a broken-pipe error. main() already special-cases ErrorKind::BrokenPipe, so extend that to the spawned-child case.
- Reap the child and propagate a sensible exit code.
Suggested default flags for less, and why:
-R raw SGR (and, with Part 1, the graphics escape is emitted once and the visible content is ordinary wide text)
-F quit if it fits on one screen, so short docs behave exactly like today
-X no alt-screen init, so output stays in scrollback
--mouse wheel scrolling
When to page by default: only when stdout is a tty. Piping (mdriver x.md | grep) must never spawn a pager. Whether --pager should be the default for tty output (bat-style) or opt-in is worth a decision in this issue — bat-style auto-paging is friendlier but is a behavior change for anyone with existing scripts, so opt-in first with a follow-up is probably right.
Interaction with #68
#68 proposes a LESS-style MDRIVER env var for default flags. These pair well: once #68 lands you could set export MDRIVER="--pager --padding 2" and get bat-like ergonomics without changing the default behavior for everyone.
Acceptance criteria
Workaround until this lands
Text-only, no images:
mdp() {
# `command` avoids the common `alias mdriver="mdriver --images kitty"`,
# since kitty a=T images are garbage inside less -R
command mdriver --color=always --width "$(tput cols)" "$@" | less -RFX --mouse
}
With images: don't page. Use mdriver --images kitty file.md and scroll the terminal's own scrollback, which is currently the only way to get text, color, and images all correct at once.
Relevant code
src/main.rs — arg parsing, ColorMode, apply_padding, the read/feed/write loop, BrokenPipe handling in main()
src/main.rs:43, README.md:101 — existing pipe-to-less advice to replace
src/lib.rs:58 — ImageProtocol enum
src/lib.rs:92 — default_width(), the min(w, 80) cap
src/lib.rs:2529 — render_image
src/lib.rs:2583-2601 — pixel-to-column math and resize (where rows must be computed)
src/lib.rs:2713 — render_kitty_image, the a=T emission site
Summary
mdriver has no built-in paging. Long documents dump straight to stdout and you rely on your terminal's scrollback. The README (README.md:101) and
--help(src/main.rs:43) suggest piping to a pager yourself:mdriver --color=always README.md | less -R...but that suggestion has three sharp edges, and one of them (images) cannot be fixed on the pager side at all. This issue proposes adding a
--pagermode, and the image-protocol change needed to make paging actually work.Current behavior and why the pipe-to-
lessadvice is lossy1. Colors are off by default when piped
ColorMode::Auto(src/main.rs) checksio::stdout().is_terminal(). Piping to a pager means no tty, so mdriver falls into passthrough mode and acts likecat. You must remember--color=alwaysevery time.2. Width is computed wrong when piped
default_width()(src/lib.rs:92) ismin(terminal_width, 80), andmain.rssubtractsMDRIVER_PADDINGfrom it. When stdout is a pipe this still works by luck on macOS (term_sizereads the tty via stderr/stdin), but it is not something to rely on, and the 80-column cap means you get 80-col output inside a 200-col pager. Observed:vs.
--width 40correctly producing 38/37/39/40. So a pager mode should pass the real terminal width explicitly.3. Images break, and no pager can fix it
This is the important one.
render_kitty_image(src/lib.rs:2713) emits:"\x1b_Gf=100,a=T,c={},m={};{}\x1b\\"Two problems:
(a)
lesswon't relay APC. Perman less,-Rpasses through onlyESC [ ... m(SGR) and OSC 8 hyperlinks in raw form. Kitty graphics are APC (ESC _ G ... ESC \), so under-Rthey get caret-escaped into visible garbage.-rdoes pass them, but then (quoting the man page) "less cannot keep track of the actual appearance of the screen... various display problems may result, such as long lines being split in the wrong place." Not viable for real paging.(b)
a=Tis fundamentally unpageable.a=Tmeans "transmit and display at the current cursor position." The image is placed by the terminal at wherever the cursor happens to be when the escape arrives, and it is not part of the text grid. So even a hypothetical pager that relayed the bytes byte-for-byte would still be wrong: scrolling doesn't move the image, because the terminal never associated it with a text line.Survey of pagers — none of them solve this as-is:
less-R;-rrelays APC but breaks screen trackingbatless, so identical limitsmoarovmost,moreProposed fix
Part 1: Unicode placeholder image mode (the enabling change)
Kitty's graphics protocol has a mode built for exactly this: Unicode placeholders. You transmit with
a=T,U=1,i=<id>and then emit a rectangle ofU+10EEEEplaceholder characters, with the image id encoded in combining diacritics. Those placeholders are real cells in the text grid, so they scroll, wrap, and are tracked correctly by any program that just passes the bytes along.This is exactly what
kitten icat --unicode-placeholderis for; its own help text says it is "useful to display images from within full screen terminal programs that do not understand the kitty graphics protocol such as multiplexers or editors."Nothing like this exists in the codebase today —
rg -n 'placeholder|U=1|10EEEE|tmux|passthrough' src/returns zero hits.Implementation notes:
ImageProtocol(src/lib.rs:58), e.g.KittyPlaceholder, wired throughrender_image(src/lib.rs:2529) andrender_kitty_image(src/lib.rs:2713).c=<cols>and let kitty derive rows from aspect ratio (see the comment at src/lib.rs:2734). We already have real pixel dimensions at the resize step (src/lib.rs:2583-2601), so computerfromimg.height()using the samePIXELS_PER_COLUMN-style assumption (will need a pixels-per-row constant; note the existing 9.0 px/col figure is already a rough guess).i=), incrementing per document. Guard against collisions with other programs by starting from a high-ish base.rlines ofccopies ofU+10EEEE, first cell carrying the row/column/id diacritics per the spec.--widthand--padding(apply_paddingin main.rs will pad it like any other line, which is what we want).Part 2:
--pagerflagAdd a
--pager/--no-pagerflag plus$MDRIVER_PAGER(falling back to$PAGER, defaultless -RFX --mouse).Behavior when paging is active:
lessstarts displaying as soon as it has a screenful, so the incremental-emission design is preserved end to end.is_terminalcheck must be bypassed rather than consulted).min(width, 80), since the pager occupies the full terminal.--images kittyto placeholder mode, since the pager can't handlea=T. Possibly warn (on stderr) if the user explicitly asked for non-placeholder kitty images together with a pager.lesswithqmid-stream should exit 0 silently, not print a broken-pipe error.main()already special-casesErrorKind::BrokenPipe, so extend that to the spawned-child case.Suggested default flags for
less, and why:-Rraw SGR (and, with Part 1, the graphics escape is emitted once and the visible content is ordinary wide text)-Fquit if it fits on one screen, so short docs behave exactly like today-Xno alt-screen init, so output stays in scrollback--mousewheel scrollingWhen to page by default: only when stdout is a tty. Piping (
mdriver x.md | grep) must never spawn a pager. Whether--pagershould be the default for tty output (bat-style) or opt-in is worth a decision in this issue — bat-style auto-paging is friendlier but is a behavior change for anyone with existing scripts, so opt-in first with a follow-up is probably right.Interaction with #68
#68 proposes a LESS-style
MDRIVERenv var for default flags. These pair well: once #68 lands you could setexport MDRIVER="--pager --padding 2"and get bat-like ergonomics without changing the default behavior for everyone.Acceptance criteria
mdriver --pager long.mdpages with colors intact, no--color=alwaysneededmdriver --pager --images kitty doc.mdshows real images that scroll correctly with the textmdriver doc.md | catis unchanged (no pager, passthrough per existing--color=autorules)--images kittywithout a pager is unchanged (stilla=T, still works when catting directly to kitty/ghostty)(echo '# a'; sleep 2; echo body) | mdriver --pager) shows the heading before the sleep finishesImageProtocolvariant per the CLAUDE.md test-first workflow--helpupdated; the existing| less -Radvice at README.md:101 and src/main.rs:43 replaced with the--pagerrecommendationcargo fmt,cargo clippy --all-targets --all-features -- -D warnings,cargo testall cleanWorkaround until this lands
Text-only, no images:
With images: don't page. Use
mdriver --images kitty file.mdand scroll the terminal's own scrollback, which is currently the only way to get text, color, and images all correct at once.Relevant code
src/main.rs— arg parsing,ColorMode,apply_padding, the read/feed/write loop,BrokenPipehandling inmain()src/main.rs:43,README.md:101— existing pipe-to-lessadvice to replacesrc/lib.rs:58—ImageProtocolenumsrc/lib.rs:92—default_width(), themin(w, 80)capsrc/lib.rs:2529—render_imagesrc/lib.rs:2583-2601— pixel-to-column math and resize (where rows must be computed)src/lib.rs:2713—render_kitty_image, thea=Temission site