Skip to main content

termflow

A streaming markdown renderer and terminal UI toolkit for modern terminals.

PyPI version Python versions License: MIT

termflow renders markdown to ANSI as it arrives, line by line, which makes it a natural fit for LLM output. It also ships the surrounding machinery a terminal-native application needs: smooth typewriter-style output pacing, terminal-wide color theming, and dependency-free interactive menus.

Two runtime dependencies: Pygments and wcwidth. No curses, no prompt_toolkit, no Rich.

Features

  • Streaming rendering — parse and render markdown incrementally, line by line, without waiting for the full document
  • Syntax highlighting — fenced code blocks highlighted via Pygments, with language detection
  • GitHub-flavored tables, ordered/unordered/nested lists, block quotes, and <think> blocks for LLM chain-of-thought
  • Word wrapping that follows the terminal — prose wraps at word boundaries (with hanging indents for lists), long code lines wrap with a ↪ marker, and new output adapts when the terminal is resized
  • Reflowing pager (tf --pager) — a scrollable view that re-wraps the whole document on every resize
  • Smooth output pacing (termflow.stream) — adaptive-rate buffering that turns bursty token streams into steady typewriter output
  • Terminal theming (termflow.themes) — bundled 16-color palettes applied terminal-wide via OSC escape sequences, with automatic restore on exit
  • Interactive menus (termflow.tui) — a declarative menu builder with search, pagination, multi-select, and live preview panes, built on plain ANSI escape codes
  • OSC 8 hyperlinks and OSC 52 clipboard integration where the terminal supports them
  • Configurable via TOML config file or programmatic API

Installation

pip install termflow-md

Or run the CLI directly:

uvx --from termflow-md tf README.md

CLI

tf README.md                  # render a file
echo "# Hello" | tf           # render stdin
tf -w 100 document.md         # fixed width (default: follow the terminal)
tf -p README.md               # pager that re-wraps on resize (stdin works too)
tf --style dracula README.md  # color preset
tf --syntax-style nord doc.md # Pygments style for code blocks
tf --list-syntax-styles       # available syntax styles

Run tf --help for the full option list.

Rendering markdown

from termflow import render_markdown

render_markdown("# Hello World")

Streaming, the primary use case:

import sys
from termflow import Parser, Renderer

parser = Parser()
renderer = Renderer(output=sys.stdout)  # no width: follows terminal resizes

for line in markdown_stream:
    renderer.render_all(parser.parse_line(line))

renderer.render_all(parser.finalize())

Custom styling:

from termflow import Renderer, RenderStyle, RenderFeatures

style = RenderStyle.dracula()  # or .nord(), .gruvbox(), .default()
style = RenderStyle(bright="#87ceeb")  # or roll your own

renderer = Renderer(
    width=100,
    style=style,
    features=RenderFeatures(clipboard=True, hyperlinks=True),
)

Smooth streaming output

Token streams arrive in bursts; printing each chunk immediately makes output stutter. termflow.stream buffers incoming text and drains it at an adaptive rate from a background asyncio task: latency stays low when the producer runs hot, and output stays smooth when it trickles.

SmoothWriter is a file-like proxy that sits between a Renderer (or any producer of ANSI text) and the real output stream. Escape sequences are emitted atomically, so styling never tears mid-sequence:

import sys
from termflow import Parser, Renderer
from termflow.stream import SmoothWriter

writer = SmoothWriter(sys.stdout)
writer.start()

renderer = Renderer(output=writer, width=80)
parser = Parser()
async for chunk in model_stream:
    renderer.render_all(parser.parse_line(chunk))

await writer.close()  # waits for the buffer to finish draining
# writer.abort()       # or: stop typing NOW and drop the backlog

StreamSmoother does the same for plain text via an emit callback, and both accept an is_paused hook to hold output while something else owns the terminal.

Terminal theming

termflow.themes recolors the whole terminal window — background, foreground, and the 16 ANSI palette slots — using xterm OSC sequences supported by iTerm2, Terminal.app, kitty, Alacritty, VS Code, GNOME Terminal, and Windows Terminal. Unsupported terminals ignore them silently. An atexit handler restores the terminal on process exit.

from termflow.themes import PALETTES, apply_palette, reset_palette

apply_palette(PALETTES["catppuccin_mocha"])
reset_palette()  # back to the terminal's own colors

Bundled palettes: Catppuccin Mocha/Latte, Tokyo Night, Solarized Light, GitHub Light, Rose Pine Dawn, and a set of originals (ocean, forest, sunset, vaporwave, green_screen, deep_black, purple_puppy, bubblegum_pink).

Each palette bridges to the markdown renderer, so themed output matches the terminal chrome:

from termflow import Renderer
from termflow.themes import get_palette

palette = get_palette("tokyo_night")
renderer = Renderer(style=palette.to_render_style())

Interactive menus

termflow.tui provides a menu component built on raw ANSI escape codes: alternate screen, arrow-key navigation, incremental search, pagination, multi-select, and a live preview pane. Every I/O surface (key source, output stream, terminal size) is injectable, so menus are testable without a tty.

from termflow.tui import MenuBuilder, MenuItem

result = (
    MenuBuilder("Pick a model")
    .items(
        [
            MenuItem("gpt-5", description="fast and smart"),
            MenuItem("claude", description="thoughtful"),
            MenuItem("qwen", description="local"),
        ]
    )
    .searchable()
    .page_size(10)
    .preview(lambda item: f"Details for {item.label}")
    .run()
)

if not result.cancelled:
    print(result.item.value)

Multi-select returns result.items; on_highlight fires on every cursor move (useful for live theme previews); disabled items render dim and are skipped by navigation.

Resizing

A Renderer without a fixed width re-checks the terminal width before every block, so output rendered after a resize fits the new size (max_width caps it on very wide terminals). Text already printed to scrollback can't be reflowed: once termflow emits a newline, the terminal owns that line. If you need the whole document to reflow, use the pager. It keeps the source and re-renders it at the new width on every resize, keeping your place:

from termflow.tui import PagerBuilder

PagerBuilder("README").markdown(open("README.md").read()).run()

# Or reflow anything: reflow(width) -> lines is re-run when the width changes
PagerBuilder("Log").reflow(lambda width: render_my_lines(width)).run()

Swappable agent histories

AgentHistory keeps independent in-memory transcripts for a main agent and its workers. Give each producer its own file-like buffer (also accepted by Renderer(output=...)), then open a live viewer:

from termflow.tui import AgentHistory, AgentHistoryViewer

history = AgentHistory()
main_output = history.add("main")
worker_output = history.add("worker")
main_output.write("Main agent started\n")
worker_output.write("Worker started\n")

# Producers can keep writing from worker threads while the viewer runs.
AgentHistoryViewer(history).run()

Tab / Right cycles forward; Left cycles backward. Each agent retains its scroll position. New output follows automatically until you scroll away from the bottom; End / G resumes following. Hidden agents continue collecting output, and agents registered while the viewer runs join the cycle. select(agent_id) switches programmatically on the viewer's thread; active_agent identifies the selection for host-provided key handlers. All normal pager navigation and close keys apply.

Buffers accept plain text or ANSI-styled, newline-delimited text, not arbitrary terminal cursor-control output. Lines are clipped to the viewport like a pager, not reflowed. Histories are retained in memory without a size limit. Buffer writes and registration are thread-safe; viewer operations belong to its UI thread. Use use_alt_screen=False inside an existing terminal_session. Only the viewer should write to the real terminal while it is open. In async applications, run the blocking viewer in a worker with coordinated input ownership and cancellation.

This is a viewing/output primitive: routing prompts, steering, cancellation, and agent lifecycles remain the host application's responsibility. It does not attach to Code Puppy or CLAI2 automatically.

Configuration

Create ~/.config/termflow/config.toml (or point TERMFLOW_CONFIG at a path):

width = 0            # 0 = auto-detect
max_width = 120
syntax_style = "monokai"

[style]
bright = "#87ceeb"   # main accent (H1/H2)
head = "#98fb98"     # H3
symbol = "#dda0dd"   # bullets, borders
link = "#87cefa"
error = "#ff6b6b"

[features]
clipboard = true     # OSC 52 clipboard for code blocks
hyperlinks = true    # OSC 8 clickable links
pretty_pad = true    # unicode borders on code blocks

See examples/config.toml for the full set of options.

Origin

termflow began as a Python port of streamdown-rs, a streaming markdown renderer written in Rust, and has since grown into a broader terminal UI toolkit.

Contributing

git clone https://github.com/mpfaffenberger/termflow.git
cd termflow
pip install -e ".[dev]"

pytest tests/ -v
ruff check .
ruff format .

Pull requests are welcome.

License

MIT. See LICENSE.

Metadata

Release files for termflow-md 0.11.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for termflow-md 0.11.0
File Size Uploaded
termflow_md-0.11.0.tar.gz 182.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for termflow-md 0.11.0
File Interpreter ABI Platform
termflow_md-0.11.0-py3-none-any.whl Python 3 none any Details

Total release size: 287.7 kB

Release files / termflow_md-0.11.0.tar.gz

Download URL termflow_md-0.11.0.tar.gz
Size 182.4 kB
Tags Source
SHA-256 checksum
How to use checksums
25706c43d80c9ddb1cad971a559f834abe89680f20887d4f5735e9968cef9e5a
BLAKE2b-256 checksum
How to use checksums
5c453d38b72453163c005e0c29c53875d81243b4339a27dbab4c4f67ff835da6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / termflow_md-0.11.0-py3-none-any.whl

Download URL termflow_md-0.11.0-py3-none-any.whl
Size 105.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
05661df6235c73de34540d11faf6bf06bd7470496621a9cb84de40bf31e48767
BLAKE2b-256 checksum
How to use checksums
66bd0b0b500fd09318dc35b2dfe2ca4405de6f6ba91f969736ba7eaa83b18562
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release history Release notifications | RSS feed

1.0.0

2 release files

This release

0.11.0 This release

2 release files

0.10.0

2 release files

0.9.3

2 release files

0.9.2

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.6

2 release files

0.2.5

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.11

2 release files

0.1.10

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page