Skip to content

Add --pager support (and Unicode-placeholder images to make paging work) #71

Description

@llimllib

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:

  1. 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.
  2. Force color on (we know we're feeding a pager on a tty, so the is_terminal check must be bypassed rather than consulted).
  3. Pass the real terminal width to the parser, not min(width, 80), since the pager occupies the full terminal.
  4. 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.
  5. 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.
  6. 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

  • mdriver --pager long.md pages with colors intact, no --color=always needed
  • Wrapping uses full terminal width in pager mode, not the 80-col cap
  • mdriver --pager --images kitty doc.md shows real images that scroll correctly with the text
  • Quitting the pager early exits 0 with no error output
  • mdriver doc.md | cat is unchanged (no pager, passthrough per existing --color=auto rules)
  • --images kitty without a pager is unchanged (still a=T, still works when catting directly to kitty/ghostty)
  • Output is still streamed, not buffered until EOF — verify a slow producer ((echo '# a'; sleep 2; echo body) | mdriver --pager) shows the heading before the sleep finishes
  • Placeholder rendering covered by tests; conformance fixtures added for the new ImageProtocol variant per the CLAUDE.md test-first workflow
  • README + --help updated; the existing | less -R advice at README.md:101 and src/main.rs:43 replaced with the --pager recommendation
  • cargo fmt, cargo clippy --all-targets --all-features -- -D warnings, cargo test all clean

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:58ImageProtocol enum
  • src/lib.rs:92default_width(), the min(w, 80) cap
  • src/lib.rs:2529render_image
  • src/lib.rs:2583-2601 — pixel-to-column math and resize (where rows must be computed)
  • src/lib.rs:2713render_kitty_image, the a=T emission site

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions