Project

Termleaf

Aug 16, 2026 GoBubble Tea v2GlamourGoldmarkSQLite

A responsive terminal Markdown reader that remembers where you stopped, even when the layout changes.

Source

I wanted reading Markdown in a terminal to feel like reading, not like piping a file into a pager.

What it is

Termleaf is a terminal Markdown reader built in Go with Bubble Tea v2. It has continuous scrolling, a terminal-sized page mode, search, bookmarks, reading progress, local images, and a dedicated canvas for Mermaid diagrams.

The feature list got longer than I expected. The real problem, though, stayed annoyingly specific: a document should keep your place when the terminal is resized and every wrapped line moves.

go install github.com/tolaniverse/termleaf/cmd/termleaf@latest
termleaf README.md
termleaf -p README.md

Architecture at a glance

cmd/termleaf
    │ opens file, restores state, starts Bubble Tea

internal/document
    │ parses Markdown into semantic source blocks

internal/app ◄──────── keyboard, resize, and async messages

    ├── internal/page       plans pages and maps reading anchors
    ├── internal/render     renders Markdown, images, and Mermaid
    └── internal/storage    persists positions and bookmarks in SQLite

cmd/termleaf is deliberately thin. It handles flags, opens the document, loads saved state, builds the app configuration, runs the Bubble Tea program, then saves the final position when the reader exits. The actual reading behavior lives below that boundary.

The read path

A Markdown file first goes through internal/document. Goldmark parses it once and the indexer records compact source ranges for top-level blocks. Lists, block quotes, fenced code, standalone images, and Mermaid blocks stay semantically intact instead of becoming one giant rendered string.

Those blocks feed two paths:

  • Scroll mode renders the full document and records which rendered lines belong to each source block.
  • Page mode incrementally packs rendered block lines into terminal-sized pages. It stores source slices and statistics, not a second full copy of every rendered page.

Both paths use the same bounded, concurrency-safe LRU cache. The cache key includes the source and render width, which matters because terminal resize changes wrapping.

The part that actually hurt

Saving line 143 sounds reasonable until the terminal gets narrower. Now line 143 may be a completely different sentence.

Termleaf saves a semantic anchor instead: the source offset of a Markdown block plus a fractional position inside that block. When the terminal size, theme, or renderer changes, the document is rendered again and that anchor is mapped back into the new layout. Older line and progress values still exist as migration fallbacks, but they are no longer the main identity of a reading position.

That same anchor model powers bookmarks and search jumps. I like this part because it removed three separate navigation problems by giving them one shared coordinate system.

Responsive work without flicker

Bubble Tea owns the UI state in internal/app. Rendering and page discovery run as commands so resize work does not block the update loop. Each request carries a generation number. If an older render completes after a newer resize or navigation event, the model rejects it instead of painting stale content.

Page mode also discovers incrementally. It plans enough pages for the current destination, renders the requested page, then looks one page ahead. The total stays unknown until discovery reaches the end, so the controls can honestly show something like 1 [2] 3 … ? instead of pretending the whole document was eagerly paginated.

Images and Mermaid

Terminal graphics are messy because support depends on the emulator, multiplexer, protocol, and cell geometry. Termleaf defaults to a conservative path: Kitty graphics when the environment is safe, ANSI half-block pixels inside tmux or uncertain terminals, and sanitized alt text when an image cannot be shown. Decode size and pixel limits keep a hostile or accidental giant image from eating the process.

Mermaid blocks remain readable as source in the document. Pressing v opens the visible diagram in a separate full-terminal canvas that can be panned without rerendering. Flowcharts, sequence diagrams, and ER diagrams use an embedded text renderer. Unsupported diagram types fall back to sanitized source instead of breaking the reading session.

Persistence and failure boundaries

Reading positions and bookmarks live in a small SQLite database under the user’s config directory. Storage errors disable persistence without making the reader unusable. The database is useful, but it should not be the reason a local Markdown file refuses to open.

The same idea shows up elsewhere. Invalid diagrams fall back to sanitized source. Unsupported images fall back to alt text. Old positions fall back to line or progress restoration. I kept trying to make the richer behavior optional while preserving the boring core behavior: open the file and let me read it.

What I focused on

Termleaf turned into an exercise in stable identity, bounded work, and terminal capability negotiation disguised as a Markdown reader. That is probably why I kept working on it. Rendering Markdown was the easy part. Making the reader stay coherent while its entire coordinate system changes was the actual project.

Source on GitHub →