From e845fefac3e975632939b80aefb0d2eee2f3bf5e Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 20 May 2026 11:43:01 +0800 Subject: [PATCH 1/4] feat: default to TUI mode, add --cat for non-interactive output Reverse the previous default: passing a file now opens the interactive TUI, and the cat-style renderer is reached via the new `--cat` flag. The old `--tui` flag still works as an explicit opt-in. Stdout is checked for TTY-ness so `termdown FILE | less` and stdin pipelines keep producing plain output without needing `--cat`. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 17 ++++++++++++----- README_CN.md | 16 +++++++++++----- src/main.rs | 29 +++++++++++++++++++++++++---- tests/cli.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1ea6e58..a4d37e9 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ glow is a great terminal Markdown renderer, but headings are only distinguished termdown rasterizes H1-H3 headings as PNG and paints them via the Kitty graphics protocol. Two modes share the same renderer: -- **Direct output** -- `cat`-like, pipe-friendly; dump rendered Markdown straight into your terminal. -- **Interactive TUI** (`--tui`) -- vim-style browser with search, Table of Contents, and link-follow navigation for longer documents. +- **Interactive TUI** (default when a file is given) -- vim-style browser with search, Table of Contents, and link-follow navigation for longer documents. +- **Direct output** (`--cat`, or automatic when stdout is piped / input comes from stdin) -- dump rendered Markdown straight into your terminal. H4-H6 headings always fall back to ANSI bold text. @@ -84,12 +84,18 @@ rm -rf ~/.termdown ## Usage ```sh -# Render a file +# Open a file in the interactive TUI (default) termdown README.md -# Pipe from stdin +# Force plain cat-style output (non-interactive, pipe-friendly) +termdown --cat README.md + +# Pipe from stdin (always cat-style — TUI needs a real file) cat notes.md | termdown +# Piped or redirected stdout also falls back to cat +termdown README.md | less + # Use a specific theme instead of auto-detect termdown --theme light README.md @@ -100,7 +106,8 @@ termdown --version ### TUI mode -For long files, use `--tui` for a vim-style interactive browser: +The TUI is launched by default whenever you pass a file and stdout is a real +terminal. You can also pass `--tui` to force it explicitly: ```sh termdown --tui README.md diff --git a/README_CN.md b/README_CN.md index 3d6fc1a..8f76121 100644 --- a/README_CN.md +++ b/README_CN.md @@ -18,8 +18,8 @@ termdown 将 H1-H3 标题栅格化为 PNG 图片,通过 Kitty 图形协议直接绘制到终端。提供两种使用模式: -- **直接输出** —— `termdown README.md`, 像 `cat` 一样轻量、管道友好,把渲染后的 Markdown 直接打到终端。适合快速查看短文档。 -- **交互式 TUI** —— `termdown --tui README.md`,类 vim/less 的体验,支持常见的翻页、搜索等快捷键,支持查看 TOC、链接跳转,适合阅读较长文档。 +- **交互式 TUI**(默认)—— `termdown README.md`,类 vim/less 的体验,支持常见的翻页、搜索等快捷键,支持查看 TOC、链接跳转,适合阅读较长文档。 +- **直接输出**(`--cat`,或当 stdout 被管道/重定向、输入来自 stdin 时自动启用)—— 像 `cat` 一样轻量、管道友好,把渲染后的 Markdown 直接打到终端。 H4-H6 标题始终以 ANSI 粗体文本渲染。不想让文档加入那么多种字重,那样反而损害可读性。 @@ -77,12 +77,18 @@ rm -rf ~/.termdown ## 使用 ```sh -# 渲染文件 +# 默认进入交互式 TUI termdown README.md -# 从 stdin 管道输入 +# 强制使用 cat 风格的纯输出(非交互、管道友好) +termdown --cat README.md + +# 从 stdin 管道输入(始终是 cat 模式 —— TUI 需要真实文件) cat notes.md | termdown +# stdout 被管道/重定向时也会自动回退到 cat +termdown README.md | less + # 指定主题(不使用终端亮色、暗色主题自动检测) termdown --theme light README.md @@ -93,7 +99,7 @@ termdown --version ### TUI 模式 -阅读较长的文档时,可以用 `--tui` 进入类似 vim 的交互浏览器: +当传入文件且 stdout 为真实终端时,默认进入 TUI。也可以显式用 `--tui` 强制开启: ```sh termdown --tui README.md diff --git a/src/main.rs b/src/main.rs index b128866..d5a2d0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod tui; use std::fs; use std::io::{self, Read}; +use crossterm::tty::IsTty; use terminal_size::{terminal_size, Width}; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -29,7 +30,9 @@ fn main() { println!(" -h, --help Show this help message"); println!(" -V, --version Show version"); println!(" --theme Color theme (default: auto-detect)"); - println!(" --tui Open FILE in interactive TUI mode"); + println!(" --cat Force non-interactive cat-style output"); + println!(" --tui Force interactive TUI mode (default when"); + println!(" FILE is given and stdout is a terminal)"); println!(); println!("Config: ~/.termdown/config.toml"); return; @@ -40,7 +43,13 @@ fn main() { return; } - let tui_mode = args.iter().any(|a| a == "--tui"); + let tui_flag = args.iter().any(|a| a == "--tui"); + let cat_flag = args.iter().any(|a| a == "--cat"); + + if tui_flag && cat_flag { + eprintln!("termdown: --tui and --cat are mutually exclusive"); + std::process::exit(2); + } check_terminal_support(); @@ -72,10 +81,22 @@ fn main() { found }; - if tui_mode { + // Decide between TUI and cat. TUI is the default when we have a real file + // path and stdout is a terminal; piping/redirecting falls back to cat so + // scripts like `termdown foo.md | less` keep working. + let want_tui = if cat_flag { + false + } else if tui_flag { + true + } else { + matches!(file_arg.as_deref(), Some(p) if p != "-") && io::stdout().is_tty() + }; + + if want_tui { let path = match file_arg.as_deref() { Some("-") | None => { - eprintln!("termdown: --tui requires a FILE argument (stdin is not supported)"); + let flag = if tui_flag { "--tui" } else { "TUI mode" }; + eprintln!("termdown: {flag} requires a FILE argument (stdin is not supported)"); std::process::exit(2); } Some(p) => p.to_string(), diff --git a/tests/cli.rs b/tests/cli.rs index ba63e40..f3f4e22 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -258,3 +258,54 @@ fn tui_with_stdin_sentinel_fails() { stderr_text(&output) ); } + +#[test] +fn file_arg_with_piped_stdout_falls_back_to_cat() { + // Default is TUI when a FILE is given, but only if stdout is a TTY. + // Tests pipe stdout, so we should get plain cat output, not a TUI error. + let file = TempMarkdownFile::new("hello from file\n"); + let output = run_termdown( + &[file.path().to_str().expect("path should be valid UTF-8")], + None, + &[("TERM_PROGRAM", "ghostty")], + &[], + ); + let stdout = strip_ansi(&stdout_text(&output)); + + assert!(output.status.success(), "stderr: {}", stderr_text(&output)); + assert!(stdout.contains("hello from file"), "stdout was: {stdout:?}"); +} + +#[test] +fn cat_flag_forces_cat_output_with_file() { + let file = TempMarkdownFile::new("plain content\n"); + let output = run_termdown( + &[ + "--cat", + file.path().to_str().expect("path should be valid UTF-8"), + ], + None, + &[("TERM_PROGRAM", "ghostty")], + &[], + ); + let stdout = strip_ansi(&stdout_text(&output)); + + assert!(output.status.success(), "stderr: {}", stderr_text(&output)); + assert!(stdout.contains("plain content"), "stdout was: {stdout:?}"); +} + +#[test] +fn cat_and_tui_together_is_an_error() { + let output = run_termdown( + &["--cat", "--tui"], + None, + &[("TERM_PROGRAM", "ghostty")], + &[], + ); + assert!(!output.status.success()); + assert!( + stderr_text(&output).contains("mutually exclusive"), + "stderr: {}", + stderr_text(&output) + ); +} From 1488a92ab158b607a94e3c2a65c54a25c8540fe0 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 20 May 2026 12:15:33 +0800 Subject: [PATCH 2/4] chore: drop dead error-branch, document --cat in CHANGELOG - Remove the unreachable `"TUI mode"` arm in the missing-file error; the implicit-TUI path already requires a real file, so only `--tui` can land in that match arm. - Add an Unreleased section to CHANGELOG.md flagging the default-mode reversal as a BREAKING change and documenting the new `--cat` flag. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 14 ++++++++++++++ src/main.rs | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d361777..b42af29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- **BREAKING:** Passing a Markdown file now opens the interactive TUI by + default instead of printing cat-style output. Use `--cat` to force the + previous non-interactive renderer. Pipes still work transparently: + `termdown FILE.md | less` and `cat FILE.md | termdown` automatically + fall back to cat-style output when stdout is not a terminal or input + comes from stdin. + +### Added +- `--cat` flag to force non-interactive cat-style output regardless of + whether stdout is a terminal. Mutually exclusive with `--tui`. + ## [0.4.0] - 2026-04-22 First release published to [crates.io](https://crates.io/crates/termdown). diff --git a/src/main.rs b/src/main.rs index d5a2d0b..e3e4bd8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,9 +94,10 @@ fn main() { if want_tui { let path = match file_arg.as_deref() { + // Only reachable via explicit `--tui`; the implicit branch above + // already requires a real file path. Some("-") | None => { - let flag = if tui_flag { "--tui" } else { "TUI mode" }; - eprintln!("termdown: {flag} requires a FILE argument (stdin is not supported)"); + eprintln!("termdown: --tui requires a FILE argument (stdin is not supported)"); std::process::exit(2); } Some(p) => p.to_string(), From 016a2f5e96d621e2f0f6413b5f9f8ce445a1cd27 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 20 May 2026 12:27:46 +0800 Subject: [PATCH 3/4] feat!: remove --tui flag With TUI as the default, `--tui` was either a no-op (when stdout is a terminal) or a footgun (forcing ratatui through a non-TTY stdout, where it can't render). Drop it. `--cat` remains the only mode switch. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 7 ++++++- README.md | 8 ++++---- README_CN.md | 6 +++--- src/main.rs | 36 +++++++++--------------------------- tests/cli.rs | 43 ------------------------------------------- 5 files changed, 22 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b42af29..be68496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `--cat` flag to force non-interactive cat-style output regardless of - whether stdout is a terminal. Mutually exclusive with `--tui`. + whether stdout is a terminal. + +### Removed +- **BREAKING:** The `--tui` flag is gone. With TUI as the default it was + either a no-op (TTY case) or a footgun (forcing TUI when stdout is not + a terminal, which ratatui can't drive). Drop it from scripts. ## [0.4.0] - 2026-04-22 diff --git a/README.md b/README.md index a4d37e9..94287d0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Render Markdown with large-font headings in the terminal using the Kitty graphic - +
termdown rendering the Chinese READMEtermdown --tui rendering the English READMEtermdown rendering the English README in TUI mode
@@ -106,11 +106,11 @@ termdown --version ### TUI mode -The TUI is launched by default whenever you pass a file and stdout is a real -terminal. You can also pass `--tui` to force it explicitly: +The TUI launches automatically whenever you pass a file and stdout is a real +terminal: ```sh -termdown --tui README.md +termdown README.md ``` Key bindings: diff --git a/README_CN.md b/README_CN.md index 8f76121..4bef0bf 100644 --- a/README_CN.md +++ b/README_CN.md @@ -5,7 +5,7 @@ - +
termdown 渲染中文 READMEtermdown --tui 渲染英文 READMEtermdown 在 TUI 模式下渲染英文 README
@@ -99,10 +99,10 @@ termdown --version ### TUI 模式 -当传入文件且 stdout 为真实终端时,默认进入 TUI。也可以显式用 `--tui` 强制开启: +当传入文件且 stdout 为真实终端时,自动进入 TUI: ```sh -termdown --tui README.md +termdown README.md ``` 按键绑定: diff --git a/src/main.rs b/src/main.rs index e3e4bd8..460cf64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,8 +31,9 @@ fn main() { println!(" -V, --version Show version"); println!(" --theme Color theme (default: auto-detect)"); println!(" --cat Force non-interactive cat-style output"); - println!(" --tui Force interactive TUI mode (default when"); - println!(" FILE is given and stdout is a terminal)"); + println!(); + println!("By default, passing FILE opens it in the interactive TUI."); + println!("Piped/redirected stdout and stdin input automatically use cat mode."); println!(); println!("Config: ~/.termdown/config.toml"); return; @@ -43,14 +44,8 @@ fn main() { return; } - let tui_flag = args.iter().any(|a| a == "--tui"); let cat_flag = args.iter().any(|a| a == "--cat"); - if tui_flag && cat_flag { - eprintln!("termdown: --tui and --cat are mutually exclusive"); - std::process::exit(2); - } - check_terminal_support(); let config = config::load(); @@ -81,27 +76,14 @@ fn main() { found }; - // Decide between TUI and cat. TUI is the default when we have a real file - // path and stdout is a terminal; piping/redirecting falls back to cat so - // scripts like `termdown foo.md | less` keep working. - let want_tui = if cat_flag { - false - } else if tui_flag { - true - } else { - matches!(file_arg.as_deref(), Some(p) if p != "-") && io::stdout().is_tty() - }; + // TUI is the default when we have a real file path and stdout is a + // terminal; --cat, piping/redirecting, or stdin input all fall through + // to cat mode so scripts like `termdown foo.md | less` keep working. + let want_tui = + !cat_flag && matches!(file_arg.as_deref(), Some(p) if p != "-") && io::stdout().is_tty(); if want_tui { - let path = match file_arg.as_deref() { - // Only reachable via explicit `--tui`; the implicit branch above - // already requires a real file path. - Some("-") | None => { - eprintln!("termdown: --tui requires a FILE argument (stdin is not supported)"); - std::process::exit(2); - } - Some(p) => p.to_string(), - }; + let path = file_arg.expect("want_tui implies a file path"); tui::run(&path, &config, theme); return; } diff --git a/tests/cli.rs b/tests/cli.rs index f3f4e22..57dcf3e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -232,33 +232,6 @@ fn unsupported_terminal_emits_warning_on_stderr() { assert!(stderr.contains("termdown: headings require Ghostty, Kitty, WezTerm, or iTerm2")); } -#[test] -fn tui_without_file_fails_with_error() { - let output = run_termdown(&["--tui"], None, &[("TERM_PROGRAM", "ghostty")], &[]); - assert!(!output.status.success()); - assert!( - stderr_text(&output).contains("--tui requires a FILE"), - "stderr: {}", - stderr_text(&output) - ); -} - -#[test] -fn tui_with_stdin_sentinel_fails() { - let output = run_termdown( - &["--tui", "-"], - Some("# hi\n"), - &[("TERM_PROGRAM", "ghostty")], - &[], - ); - assert!(!output.status.success()); - assert!( - stderr_text(&output).contains("--tui requires a FILE"), - "stderr: {}", - stderr_text(&output) - ); -} - #[test] fn file_arg_with_piped_stdout_falls_back_to_cat() { // Default is TUI when a FILE is given, but only if stdout is a TTY. @@ -293,19 +266,3 @@ fn cat_flag_forces_cat_output_with_file() { assert!(output.status.success(), "stderr: {}", stderr_text(&output)); assert!(stdout.contains("plain content"), "stdout was: {stdout:?}"); } - -#[test] -fn cat_and_tui_together_is_an_error() { - let output = run_termdown( - &["--cat", "--tui"], - None, - &[("TERM_PROGRAM", "ghostty")], - &[], - ); - assert!(!output.status.success()); - assert!( - stderr_text(&output).contains("mutually exclusive"), - "stderr: {}", - stderr_text(&output) - ); -} From 1d09b94d71283f26f378d6c91d0dcf7459738ba7 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 20 May 2026 14:31:18 +0800 Subject: [PATCH 4/4] docs: clarify version-bump workflow as direct-to-master + tag Document the bump exception to the no-direct-commits rule: version bumps are the only commits that go straight to master, kept as standalone commits (not bundled into feature PRs). Pushing the vX.Y.Z tag both stamps the version and triggers release.yml. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index efa195e..690894d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,3 +38,17 @@ Add it as a Makefile target first, then reference the target from CI. Never let 3. Never run `git commit` while `HEAD` is on `master`, and never run `git push origin master` — even for "small" doc tweaks. Committing to local master (even without pushing) tends to contaminate the merge base of later feature branches. If you notice you're on `master` with uncommitted changes, stash them, switch to a new branch, and pop the stash before committing. + +### Exception: version bumps + +Version bumps are the **only** allowed direct-to-`master` commits. They must not ride along inside a feature/fix PR — keep them as standalone commits so the release history stays readable. + +When the feature/fix PRs that make up a release have all merged into `master`: + +1. `git checkout master && git pull` to land on the merged tip. +2. Edit `Cargo.toml` (and `Cargo.lock` — `cargo build` will refresh it) to the new version. +3. Lock the `CHANGELOG.md` `[Unreleased]` section to `[X.Y.Z] - YYYY-MM-DD`. +4. `git commit -m "chore: bump version to X.Y.Z"` on `master`. +5. `git tag vX.Y.Z` and `git push origin master --follow-tags`. + +The `release.yml` workflow triggers on `v*` tag pushes and produces the GitHub Release (cross-platform binaries + checksums). Pushing the tag is therefore both the version stamp **and** the release trigger — no extra step.