Skip to content

feat: termynal output mode for the directive - #35

Merged
0x054 merged 20 commits into
syn54x:mainfrom
FBumann:feat/termynal-output
Jun 16, 2026
Merged

feat: termynal output mode for the directive#35
0x054 merged 20 commits into
syn54x:mainfrom
FBumann:feat/termynal-output

Conversation

@FBumann

@FBumann FBumann commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Feature request: #36

Hi,
I'm trying to document CLI Typer apps. I really want to document them like they'd be shown in a terminal, so I thought I could combine your repo with https://github.com/termynal/termynal.py to achieve nice, colored documentation. I hope you like it and approve it.

Note

This description was drafted with AI assistance (Claude); the code is accumulated fork work. Happy to trim or adjust anything.

What

Adds a termynal output mode to the ::: mkdocs-typer2 directive. Instead of Markdown tables, it renders a CLI's --help as an animated, colored terminal via termynal:

::: mkdocs-typer2
    :module: my_module.cli
    :name: mycli
    :termynal: true

By default only the root command's --help is rendered. Use :command: to render a specific subcommand instead, and :subcommands: to stack a depth of subcommand blocks below the selected command. Enable the mode per block (:termynal: true) or globally (termynal: true).

Options

Each render option is available per block (:option:) and globally (termynal_-prefixed, e.g. termynal_width); the block-level value wins. :command: is block-level only.

Option Purpose
command render a specific subcommand instead of the root, via a space-separated path (export, or subapp sub-command for nested). :subcommands: recursion applies relative to it. Block-level only
subcommands recursion depth: 0 selected command only (default), N levels deep, -1 the full tree. Hidden commands skipped at every level
width capture width in columns (default 80; floored at 1)
scheme ansi2html palette; invalid → xterm
dark_bg light/dark background variant
buttons window chrome macos | windows; invalid → macos
prompt prompt symbol (default $)
type_delay / line_delay / start_delay termynal animation timings (ms); unset → termynal's own defaults

Render options are bundled in a TermynalOptions dataclass with Literal-typed domains; the option domains and data-ty-* attribute names live as module-level constants in termynal_render.py as the single source of truth. The per-option defaults are sourced from that dataclass by both entry points (the MkDocs config_scheme and the Markdown-extension constructor used by Zensical), so the two can't drift apart.

Per-command blocks

Because :command: selects exactly one command, you can document one command per block with your own headings and prose around each — instead of one giant stacked terminal:

## Export

::: mkdocs-typer2
    :module: my_module.cli
    :name: mycli
    :termynal: true
    :command: export

renders exactly mycli export --help.

How it works

  • In-process introspection — reuses the existing native Click resolution; no subprocess. Each command's --help is captured by forcing rich's terminal output, so Typer apps come out colored (plain Click apps render monochrome). Deterministic and cross-platform. Because the renderer only ever produces help text, the prompt line is always <cmd> --help so it matches the output exactly. Hidden commands are skipped during recursion, matching --help; selecting a hidden command explicitly via :command: still renders it.
  • Recursion is opt-in — the default renders a single block (the selected command's --help), matching a bare <cmd> --help. :subcommands: 1 adds one block per non-hidden direct subcommand, higher values go deeper (depth-first, parent-then-descendants), and -1 walks the whole tree.
  • ANSI → HTML via ansi2html, wrapped in termynal's data-ty markup and injected through the markdown htmlStash. Because it's a markdown extension, it works under both MkDocs and Zensical.
  • No new hard dependencytermynal + ansi2html are an optional extra, mkdocs-typer2[termynal]. Using the mode without it raises a clear install hint, and the imports are deferred to when the mode is actually used, so non-termynal users pull nothing extra.
  • Loose coupling — emits termynal's markup directly rather than importing its renderer (its convert() escapes output, which would strip the color). Drift guards in tests/test_termynal_contract.py assert the markup tokens against termynal's own output, the button attributes against its CSS, and the timing attributes against its JS — so a future termynal rename fails loudly.
  • Stacked-block spacing is owned here (termynal styles [data-termynal] with padding but no margin); a margin-top is applied only between stacked blocks, so a lone block imposes no margin on surrounding page content.

Docs & scope

  • A CLI (Termynal) page is added to the docs site nav, and the termynal MkDocs plugin is enabled so its CSS/JS animate the blocks (builds clean under mkdocs build --strict). The README documents the mode and every option.
  • Existing legacy / native / pretty behavior is unchanged when :termynal: is off.

Tests

tests/test_termynal_render.py (colored Typer, monochrome Click fallback, root-only default, :command: selection incl. nested and three-level sub-subcommand paths / unknown-path error / recursion relative to the selection, subcommand recursion at depth 1 and unlimited -1 verified to reach a third level, each option + invalid-value fallbacks, directive/plugin threading, end-to-end htmlStash injection) and tests/test_termynal_contract.py (markup / CSS / JS drift guards). Both skip cleanly when the termynal extra isn't installed.

A CHANGELOG entry is added under [Unreleased].

FBumann and others added 11 commits June 15, 2026 20:37
Render a Typer/Click app's --help as an animated, colored termynal block
instead of Markdown tables, via the :termynal: (and :width:) directive options
or the global plugin config. The app is introspected in-process: each command's
--help is captured with rich's terminal output forced, hidden commands skipped,
ANSI converted to inline HTML with ansi2html, and wrapped in termynal's data-ty
markup. No subprocess, deterministic, and it runs under both mkdocs and zensical
since it is a markdown extension.

termynal and ansi2html are an optional extra (mkdocs-typer2[termynal]); using
:termynal: without it raises a clear install hint. The markup is emitted
directly rather than importing termynal's renderer, and a contract test guards
it against drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose the ANSI color palette and dark/light variant for termynal mode, as
per-block directive options (:scheme:, :dark_bg:) and global plugin config,
mirroring :width:. scheme accepts ansi2html's eight palettes (ansi2html,
dracula, mint-terminal, osx, osx-basic, osx-solid-colors, solarized, xterm) and
falls back to xterm on an invalid value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The drift-guard test called escape() as a method on the Termynal
instance, which raises AttributeError on termynal>=0.12 where escape is
a module-level function. All 7 parametrized cases failed, so the guard
never actually ran. Import and use the module-level escape instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
termynal styles [data-termynal] with padding but no margin, and exposes
no wrapper class or inter-block spacing hook. Normal termynal usage
doesn't notice because each terminal is its own Markdown block; we emit
several <div data-termynal> glued together in one HTML-stash blob, so
the root command and per-subcommand blocks stacked flush.

Add margin-top only to blocks after the first, so stacked blocks are
separated without imposing a margin on the boundary between the
first/last block and surrounding page content (that stays the theme's
concern). A lone block gets no inline style at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_resolve_app in termynal_render duplicated the import + getattr-fallback
+ _resolve_click_command dance already in build_tree_from_click_app.
Extract it as pretty.resolve_click_command and call it from both, so the
two output modes can't drift in how they locate the app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The top-level width/scheme/dark_bg plugin options were generic and only
meaningful in termynal mode. Rename to termynal_width/termynal_scheme/
termynal_dark_bg so the global config reads unambiguously and the names
won't collide with future options. Block-level :width:/:scheme:/:dark_bg:
stay as-is since they're already scoped under the directive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a tracked CLI (Termynal) page alongside the other mode pages, wire
it into the nav, and enable the termynal MkDocs plugin so its CSS/JS
animate the generated blocks. Builds clean under mkdocs --strict.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the width/scheme/dark_bg kwargs (previously re-listed across
TyperExtension and TyperProcessor and threaded individually into the
renderer) into a single TermynalOptions dataclass, defined in
termynal_render.py alongside the option domains. Option values are now
Literal-typed (AnsiScheme, ButtonStyle) with their tuples derived via
get_args, and the data-ty-* attribute names live in module-level
globals as the single source of truth. The directive parser is DRY'd
behind _directive_value / _as_bool / _as_int helpers.

No behavior change for the existing width/scheme/dark_bg options.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose buttons (macos|windows), prompt, and the type/line/start delay
timings as both directive (:buttons:, :prompt:, :type_delay:, ...) and
plugin (termynal_buttons, ...) options, mapped to the matching data-ty-*
attributes. buttons falls back to macos on invalid input; timings emit
attributes only when set, otherwise termynal's own defaults apply.

Drift guards now assert our button attributes appear in termynal's CSS
and our timing attributes in termynal.js, so a future termynal rename
fails loudly against the BUTTONS / TIMING_ATTRS source of truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FBumann and others added 4 commits June 15, 2026 21:55
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render only the root command's --help by default instead of always
stacking root plus its direct subcommands. Add a `subcommands`
recursion-depth option, available per block (`:subcommands:`) and
globally (`termynal_subcommands`): 0 renders the root only (default),
N renders N levels, and -1 renders the full tree. Hidden commands are
skipped at every level, matching --help.

Also harden and DRY the option handling: floor `width` at 1 in
normalization so a non-positive value can't reach rich.Console, and
source every per-option default from the TermynalOptions dataclass so
the MkDocs config_scheme and the Markdown-extension (Zensical) entry
points can no longer drift apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Select a subcommand to render instead of the root via a space-separated
path: `:command: export` renders `<cli> export --help`, and
`:command: subapp sub-command` selects a nested command. `:subcommands:`
recursion applies relative to the selected command, so you can document
one command per block with your own surrounding headings and prose.

Explicit selection renders the command even if it is hidden; an unknown
path raises a clear ValueError. Block-level only (selecting a fixed
subcommand globally makes no sense).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a three-level synthetic Typer fixture (root -> middle -> inner ->
leaf-cmd) and assert both mechanisms reach the third level: a
`:command: middle inner leaf-cmd` path selects the sub-subcommand, and
`:subcommands: -1` recurses down to it while `:subcommands: 1` stops
short. The repo's own CLI is only two levels deep, so this was
previously unverified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@0x054

0x054 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Hi there! Thanks for opening a pull-request! I like the idea. Is your PR ready for review / testing? Happy to prioritize it and get it released if so.

@FBumann

FBumann commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Hi great to hear!

Not yet. Ill mark it as ready as soon as i hardened it.

FBumann and others added 3 commits June 16, 2026 08:13
…t tests

_colored_help swaps typer.rich_utils._get_rich_console to capture colored
--help; typer exposes no public hook for this. Make the coupling explicit and
resilient: reference the symbol via TYPER_RICH_CONSOLE_HOOK, and if it ever
disappears, render through a stdout-redirected fallback so help comes out
monochrome instead of crashing the build or leaking to the console.

Add test_typer_rich_console_hook_present (contract drift guard; needs only
typer, so it runs without the termynal extra) and a fallback-branch test
asserting rendering still emits a block without crash or stdout leak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… helpers

Route the :prompt: directive through _directive_line so a prompt containing
spaces (e.g. `my $`) is kept whole instead of truncated at the first token by
the \S+ capture; the other scalar options stay single-token. Add a directive
test for the multi-word prompt and unit tests for the _as_bool/_as_int
invalid-value fallback branches (markdown.py now fully covered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The _as_bool/_as_int coverage tests landed in test_plugin.py (a general
plugin test file); relocate them to a termynal-scoped test_termynal_directive.py
so the termynal feature keeps its own test footprint. test_plugin.py is
restored to its prior state; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FBumann
FBumann marked this pull request as ready for review June 16, 2026 06:23
@FBumann

FBumann commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

@0x054 Its ready

@0x054

0x054 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

@FBumann Thanks again! Great work here, really appreciate it. Just a couple requests:

  1. Would you mind opening a feature request and linking it in the pr description?
  2. Termynal output doesn't appear to work correctly when rendering the docs using Zensical
image

@FBumann

FBumann commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

@0x054 Sure.

And ill check zensical

@0x054
0x054 self-requested a review June 16, 2026 13:14
@0x054 0x054 added the enhancement New feature or request label Jun 16, 2026
@FBumann

FBumann commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Zensical needs the termynal css/js injected manually. Luckily, termynal exposes a public api for this!

…vascript

Zensical does not run termynal's MkDocs plugin, so its CSS/JS must be added
explicitly or the blocks render as unstyled text. Document a CDN one-liner
(pinned to the installed termynal version so the assets match the emitted
markup) plus a self-hosted fallback, and warn against inlining the CSS/JS into
page content (Zensical folds raw <style> text into the page title/heading).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FBumann

FBumann commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

@0x054 I documented how to get it working for zensical. As zensical does not yet have a way of injecting js/css, this needs some work from the user (minimal)

@FBumann

FBumann commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

@0x054 Opened the issue

@0x054

0x054 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

@FBumann Awesome! I think just take care of the Build & Test failures and I'll approve!

Collapse a call that fit on one line; fixes the ruff-format
pre-commit hook failing in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FBumann

FBumann commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

@0x054 Done. Was just a ruff format. And a weird codecov timeout, which should be unrelated to my changes...

@0x054 0x054 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks again!

@0x054
0x054 merged commit 4481fdf into syn54x:main Jun 16, 2026
1 of 2 checks passed
@0x054 0x054 mentioned this pull request Jun 16, 2026
2 tasks
0x054 added a commit that referenced this pull request Jun 16, 2026
## Summary

Prepare **0.4.0** for release:

- Bump `version` in `pyproject.toml` to `0.4.0`
- Document termynal output mode and related docs merged since `0.3.1` in
`CHANGELOG.md`

## Changes in 0.4.0

- **Added:** `termynal` output mode for animated CLI `--help` rendering
([#35](#35))
- **Added:** Termynal directive options (`:command:`, `subcommands`,
styling/timing globals)
- **Added:** Termynal docs page and Zensical asset setup notes

## After merge

1. Create GitHub Release `v0.4.0` (publish) to trigger PyPI upload via
`publish.yml`
2. Docs site updates automatically on merge to `main`

## Test plan

- [x] Changelog and version bump only
- [ ] CI passes on this PR


Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <cursoragent@cursor.com>
@0x054

0x054 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

@FBumann I've released your feature. You can install v0.4.0!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: termynal output mode — render --help as an animated, colored terminal

2 participants