throughline render and branch already write a self-contained HTML file and print its path, but opening it was a manual second step. --open does it for you, shelling out to the platform opener — for both subcommands.
The flag is a plain boolean on each subcommand, long-form only since -o is already --output. Here it is on render, mirrored verbatim on branch:
27+ /// After writing the page, open it with the platform's default handler.28+ #[arg(long)]29+ open: bool,Once the page is written and its path printed, a new helper picks the opener by platform — open on macOS, xdg-open on Linux, cmd /C start on Windows — and runs it. It is best-effort: the render is the real deliverable, so a missing or failing opener warns to stderr and still exits 0 rather than failing the command. (Command is fully qualified because clap's Command enum is already imported here.)
118+/// Open a rendered page with the platform's default handler. Best-effort: the119+/// file is already written and its path printed, so a missing or failing opener120+/// warns but never fails the command. (`Command` here is fully qualified because121+/// the crate's clap `Command` enum is already in scope.)122+fn open_in_browser(path: &Path) {123+ let result = if cfg!(target_os = "macos") {124+ std::process::Command::new("open").arg(path).status()125+ } else if cfg!(target_os = "windows") {126+ // `start` is a cmd builtin; the empty "" is the (required) window title.127+ std::process::Command::new("cmd")128+ .args(["/C", "start", ""])129+ .arg(path)130+ .status()131+ } else {132+ std::process::Command::new("xdg-open").arg(path).status()133+ };134+ match result {135+ Ok(status) if status.success() => {}136+ Ok(status) => eprintln!(137+ "warning: opener exited with {status}; the page is at {}",138+ path.display()139+ ),140+ Err(e) => eprintln!(141+ "warning: couldn't launch the platform opener ({e}); the page is at {}",142+ path.display()143+ ),144+ }145+}The binary is the one layer with side effects, and the integration suite links only the library — so main.rs has no test seam and --open is verified by running the binary, not by cargo test. A new wiki field note records that boundary, the best-effort opener, and the empty-PATH trick for hitting the warning branch without launching a browser; the README usage, the branch how-to, and the ROADMAP entry are updated alongside (and a stale v0.3 spec caption corrected to v0.4).
86 ## Usage87 88 ```sh-throughline render <commit> [-o out.html]89+throughline render <commit> [-o out.html] [--open]90 ```91 92 - `<commit>` is any revision: `HEAD`, a SHA, a tag, `HEAD~2`, …93 - Omit `-o` and it writes `throughline-<short-sha>.html` in the current directory.94+- `--open` opens the page in your default browser after writing it (`open` on macOS,95+ `xdg-open` on Linux, `start` on Windows). It is best-effort: if no opener is found96+ the path is still printed and the command succeeds.97 - `--debug` prints the parsed title / TL;DR / intro / anchors instead of HTML — handy98 for checking how a message parses.99 104 with a contents list and previous/next links:105 106 ```sh-throughline branch <tip> [--base <ref>] [-o out.html] # range is base..tip; default base: main-throughline branch <base>..<tip> [-o out.html] # explicit range107+throughline branch <tip> [--base <ref>] [-o out.html] [--open] # range is base..tip; default base: main108+throughline branch <base>..<tip> [-o out.html] [--open] # explicit range109 ```110 111 - `throughline branch my-feature` renders every commit in `main..my-feature`.112 - `throughline branch v1.0..v1.1` renders an explicit range.113 - Omit `-o` and it writes `throughline-branch-<tip-sha>.html`.114+- `--open` works here too — render the branch page and open it in one step.115 - Completeness still holds **per commit** — each chapter diffs against its own116 parent; the page composes the commits in order rather than squashing them.117 118 The output is a single standalone HTML file (inline CSS, no JavaScript, no external-assets), so you can open it directly:119+assets), so you can open it directly — `--open` does exactly that for you:120 121 ```sh-throughline render HEAD -o /tmp/commit.html && open /tmp/commit.html # macOS-# Linux: xdg-open /tmp/commit.html122+throughline render HEAD --open # render, then open it123+throughline render HEAD -o /tmp/commit.html && open /tmp/commit.html # or do it by hand (macOS)124 ```125 126 ## Using it in any project (no Rust required)133 134 ```sh135 git config --global alias.tl \- '!f(){ throughline render "${1:-HEAD}" -o /tmp/tl.html && open /tmp/tl.html; }; f'136+ '!f(){ throughline render "${1:-HEAD}" --open; }; f'137 # then, in any repo:138 git tl # HEAD139 git tl <sha> # a specific commit193 render.rs resolve anchors -> ordered document model194 html.rs emit a self-contained HTML page195 docs/- SPEC.md canonical format specification (v0.3)196+ SPEC.md canonical format specification (v0.4)197 ARCHITECTURE.md implementation design and module map198 ROADMAP.md shipped work and the prioritized plan ahead199 llm-commit-guide.md instruction for LLM commit authors163 ([`docs/wiki/automation/docs-sync-hook.md`](wiki/automation/docs-sync-hook.md)).164 - [ ] **Golden/snapshot tests** for the HTML output (e.g. `insta`) so rendering165 changes are reviewable as a diff.-- [ ] **`--open` flag** — render and open the page in one step, for **both**- `render` and `branch`. Shell out to the platform opener (`open` on macOS,- `xdg-open` on Linux, `start` on Windows). *(Planned for a dedicated session.)*166+- [x] **`--open` flag** — render and open the page in one step, for **both**167+ `render` and `branch`. Shells out to the platform opener (`open` on macOS,168+ `xdg-open` on Linux, `start` on Windows). Best-effort: the page is written and its169+ path printed first, so a missing or failing opener warns to stderr but still exits170+ 0 — `--open` stays safe in scripts and on headless boxes. *(`cli.rs` flag +171+ `main.rs` `open_in_browser` helper.)*172 - [x] **Pre-commit hook** running `fmt` / `clippy -D warnings` / `test` locally before173 every commit — the local half of the CI checks, with a non-Rust fast path and a174 `--no-verify` bypass41 - [`field-notes/html-output.md`](field-notes/html-output.md)42 — how `html.rs` shapes the page, and the two gotchas (line-packing, CSS class43 names) that mislead you when you grep or split rendered output.44+ - [`field-notes/cli-binary-layer.md`](field-notes/cli-binary-layer.md)45+ — the binary (`main.rs`): where I/O lives, the best-effort `--open` opener, and46+ why `cargo test` can't reach the CLI (so how to verify it instead).47 - **How-to** — acting on this codebase.48 - [`how-to/render-a-branch.md`](how-to/render-a-branch.md) — render a range of49 commits as one navigable page (`throughline branch`); the input forms and the1+# Field note: the binary layer (`main.rs`)2+3+Where the side effects live, and why `cargo test` doesn't reach them. Read this4+before adding or changing CLI behavior — output paths, flags, the `--open` opener —5+so you know what is testable and how to verify the rest.6+7+## The library returns data; the binary does I/O8+9+`commit_throughline` (the library: `render`, `html`, `git`, …) is effectively pure10+at its surface — it resolves a commit into a `Document` and lowers that into an HTML11+*string*. It writes nothing. Every side effect lives in the binary, `src/main.rs`:12+discover the repo, call the library, **write** the file, **print** the path, and —13+with `--open` — **spawn** the platform opener. `run_render` / `run_branch` are the14+two top-level flows; both end with the same three steps (write → print → maybe open).15+16+## `--open` is best-effort, by design17+18+`open_in_browser` (`src/main.rs`) shells out to the platform opener — `open` on19+macOS, `xdg-open` on Linux, `cmd /C start "" <path>` on Windows — selected with20+`cfg!(target_os = …)` (one function, every branch type-checks on every platform,21+only the matched branch runs). It is called *after* the page is written and its path22+printed, so the deliverable is already on disk. If the opener is missing or exits23+non-zero, it warns to stderr and the command still exits 0 — the render is the24+product; a viewer that won't launch must not fail it. This is the same25+"degrade, don't fail" stance the renderer takes with a bad anchor. Tracked under26+the `--open` flag in [`ROADMAP.md`](../../ROADMAP.md).27+28+**Gotcha:** `main.rs` already imports clap's `Command` (the subcommand enum), so29+`std::process::Command` is referred to **fully qualified** — a bare30+`use std::process::Command` would collide. Keep the qualification (or alias) if you31+move the helper.32+33+## The integration tests do not reach the binary34+35+`tests/` link the **library** and call its functions directly (`render::render`,36+`html::to_html`, …) over fixture repos built with `tempfile` + `git2`. They never37+spawn the `throughline` binary. So `run_render` / `run_branch` / `open_in_browser`38+are private to `main.rs` and **uncovered** by `cargo test`: output-path naming, the39+`--open` opener, and `--debug` are binary-layer behavior you verify by **running the40+binary**, not by adding a test. (This is the practical edge of ARCHITECTURE's "the41+I/O and rendering modules wrap the pure ones" — the outermost I/O wrapper, `main`,42+has no test seam at all.)43+44+Recipe — exercise the best-effort failure branch *without* launching a browser: run45+the built binary by path with an emptied `PATH`, so the process execs fine but its46+child opener lookup fails:47+48+```sh49+PATH="" target/debug/throughline render HEAD -o /tmp/x.html --open50+# prints "Rendered …", then "warning: couldn't launch the platform opener …", exits 051+```52+53+The happy path is a plain `target/debug/throughline render HEAD --open` (it really54+opens a browser tab, so do it sparingly).20 21 Both resolve to the same thing internally: the commits in `base..tip` — reachable22 from the tip but not the base — so the **base commit itself is excluded**. Output-goes to `throughline-branch-<tip-sha>.html` unless you pass `-o`.23+goes to `throughline-branch-<tip-sha>.html` unless you pass `-o`. Add `--open` to24+open the page in your default browser once it is written (best-effort — same flag as25+`render`).26 27 ## What you get28 24 #[arg(short, long)]25 output: Option<PathBuf>,26 27+ /// After writing the page, open it with the platform's default handler.28+ #[arg(long)]29+ open: bool,30+31 /// Print the parsed message structure instead of rendering HTML.32 #[arg(long)]33 debug: bool,50 /// Write the HTML here instead of `throughline-branch-<sha>.html`.51 #[arg(short, long)]52 output: Option<PathBuf>,53+54+ /// After writing the page, open it with the platform's default handler.55+ #[arg(long)]56+ open: bool,57 },58 }13 Command::Render {14 commit,15 output,16+ open,17 debug,18 } => {19 if debug {20 debug_message(&commit)21 } else {- run_render(&commit, output.as_deref())22+ run_render(&commit, output.as_deref(), open)23 }24 }25 Command::Branch {26 branch,27 base,28 output,- } => run_branch(&branch, &base, output.as_deref()),29+ open,30+ } => run_branch(&branch, &base, output.as_deref(), open),31 }32 }33 -fn run_render(commit: &str, output: Option<&Path>) -> Result<()> {34+fn run_render(commit: &str, output: Option<&Path>, open: bool) -> Result<()> {35 let repo =36 git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;37 53 doc.sections.len(),54 path.display()55 );56+57+ if open {58+ open_in_browser(&path);59+ }60 Ok(())61 }62 63 /// Render a range of commits as one navigable page (the `branch` subcommand).-fn run_branch(branch: &str, base: &str, output: Option<&Path>) -> Result<()> {64+fn run_branch(branch: &str, base: &str, output: Option<&Path>, open: bool) -> Result<()> {65 let repo =66 git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;67 108 title,109 path.display()110 );111+112+ if open {113+ open_in_browser(&path);114+ }115 Ok(())116 }117 118+/// Open a rendered page with the platform's default handler. Best-effort: the119+/// file is already written and its path printed, so a missing or failing opener120+/// warns but never fails the command. (`Command` here is fully qualified because121+/// the crate's clap `Command` enum is already in scope.)122+fn open_in_browser(path: &Path) {123+ let result = if cfg!(target_os = "macos") {124+ std::process::Command::new("open").arg(path).status()125+ } else if cfg!(target_os = "windows") {126+ // `start` is a cmd builtin; the empty "" is the (required) window title.127+ std::process::Command::new("cmd")128+ .args(["/C", "start", ""])129+ .arg(path)130+ .status()131+ } else {132+ std::process::Command::new("xdg-open").arg(path).status()133+ };134+ match result {135+ Ok(status) if status.success() => {}136+ Ok(status) => eprintln!(137+ "warning: opener exited with {status}; the page is at {}",138+ path.display()139+ ),140+ Err(e) => eprintln!(141+ "warning: couldn't launch the platform opener ({e}); the page is at {}",142+ path.display()143+ ),144+ }145+}146+147 /// `--debug`: print what the message parser understands, without rendering.148 fn debug_message(commit: &str) -> Result<()> {149 let repo =