fireside-cli
fireside-cli is the binary crate that users run directly. It owns exactly
three things: CLI argument parsing (clap), terminal lifecycle management
(crossterm), and the main event loop. All other logic — graph loading,
validation, rendering, state — lives in the library crates below it.
Crate boundaries
| Responsibility | fireside-cli | Delegated to |
|---|---|---|
| Argument parsing | ✓ | — |
| Terminal enter/exit | ✓ | — |
| Event loop | ✓ | — |
| Hot-reload file watching | ✓ | — |
| Graph loading | ✗ | fireside-engine::load_graph |
| Validation | ✗ | fireside-engine::validation::validate_graph |
| State machine | ✗ | fireside-tui::App |
| Rendering | ✗ | fireside-tui::App::view |
| Theme resolution | ✗ | fireside-tui::config::resolve_theme |
| Scaffolding templates | ✗ | commands/scaffold.rs |
This boundary means main.rs is under 160 lines and contains no business logic.
Subcommand register
The Command enum is parsed by clap::Parser:
fireside present <file.json> [--theme <name>] [--start <n>]fireside open <dir> [--theme <name>]fireside edit [path]fireside new <name> [--project] [--dir <path>]fireside validate <file.json>fireside fontsfireside import-theme <file.itermcolors> [--name <name>]None (no subcommand) prints a compact usage summary and exits successfully.
Clap’s --help and --version flags are generated automatically from the
#[command(name, version, about)] attributes.
Terminal lifecycle
Both run_presentation and run_editor follow the same lifecycle pattern:
enable_raw_mode()execute!(stdout, EnterAlternateScreen, EnableMouseCapture)Terminal::new(CrosstermBackend::new(stdout)) └─► run_event_loop(terminal, app, settings.poll_timeout_ms, watch_path)disable_raw_mode()execute!(backend, LeaveAlternateScreen, DisableMouseCapture)terminal.show_cursor()All crossterm setup and teardown calls are wrapped with .context(...) to
produce clean anyhow::Error chains if the terminal is unavailable (e.g.,
not attached to a TTY).
Teardown runs unconditionally after run_event_loop returns, whether it
succeeded or failed. This prevents the terminal being left in raw mode if
the TUI panics or returns early with an error.
The event loop
run_event_loop is fireside-cli’s most important function. It is the
bridge between crossterm events and App::update:
fn run_event_loop( terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App, idle_poll_timeout_ms: u64, watch_path: Option<&Path>,) -> Result<()>Frame draw gate
Frames are drawn lazily, only when app.take_needs_redraw() returns true.
The App sets its redraw flag whenever state changes (any Action processed
by update that could alter the visual output). This avoids redundant redraws,
which matters on large terminals where terminal.draw is the hot path.
Quit detection follows draw: app.should_quit() is checked immediately after
draw, before any blocking poll.
Poll timeout adaptation
Two poll durations are in play:
let poll_duration = if app.is_animating() { Duration::from_millis(50) // ~20 fps for smooth transitions} else { Duration::from_millis(idle_poll_timeout_ms.max(10)) // user-configured idle};When transitions are active, the loop drives at approximately 20 fps via
Action::Tick injections if no real event arrived. When idle, the loop
blocks on event::poll for the configured duration (default: 50–200 ms
depending on settings). This prevents CPU spinning during idle presentation
while maintaining smooth animation during transitions.
Input dispatch
When an event is available:
app.handle_event(ev);App::handle_event maps the crossterm::event::Event to an Action via the
keybinding configuration and calls App::update(action). The CLI does not
interpret events directly; it delivers raw crossterm events to the TUI layer.
Hot-reload
If watch_path is set (only in presentation mode, not edit mode), the event
loop polls for file modification on every iteration:
if app.can_hot_reload() && let Some(path) = watch_path && let Some(updated_modified) = file_modified_time(path) && last_modified.is_none_or(|prev| updated_modified > prev){ if let Ok(graph) = load_graph(path) { app.reload_graph(graph); } last_modified = Some(updated_modified);}file_modified_time(path) reads fs::metadata(path)?.modified() and returns
None on any error. Modification time is compared to the last-known mtime stored
in last_modified: Option<SystemTime>.
app.can_hot_reload() is a guard that the TUI sets false during edit mode and
certain fragile states. On a successful reload, load_graph is called directly
(not through the engine session); if it fails the stale graph remains visible
and no error is shown (the next file-write will be retried).
present — run_presentation
pub fn run_presentation(file: &Path, theme_name: Option<&str>, start_node: usize) -> Result<()>Sequence:
load_graph(file)— parse and index the graph viafireside-engineload_settings()— read~/.config/fireside/config.json- Resolve effective theme:
--themeflag → documentmeta.theme→ settings → default PresentationSession::new(graph, start_node - 1)— 0-indexed internallyApp::new(session, theme)— construct TUI state machine- Apply settings:
set_show_progress_bar,set_show_elapsed_timer - Enter raw mode + alternate screen →
run_event_loop(…, Some(file))→ cleanup
The start_node argument is 1-indexed on the CLI (--start 3 means the third
node listed in the nodes array) and converted to 0-indexed before passing to
the engine.
edit — run_editor
pub fn run_editor(target: &Path) -> Result<()>Accepts either a file path or a project directory. If target.is_dir(),
resolve_project_entry(target) opens fireside.json in that directory and
finds the primary entry point (the first file in nodes).
The key difference from run_presentation:
app.enter_edit_mode()is called before the event loop, settingAppMode::Editingas the initial mode.app.set_editor_target_path(graph_path)configures the path thatAction::SaveGraphwill write to.watch_pathis passed asNonetorun_event_loop, disabling hot-reload (avoiding a collision between the editor writing and the watcher reloading).
validate — run_validate
pub fn run_validate(file: &Path) -> Result<()>Non-interactive. Sequence:
load_graph(file)— errors here (malformed JSON, unknown fields) are printed asanyhowchain errors.validate_graph(&graph)— returnsVec<Diagnostic>with error/warning severity.- Print all diagnostics to stdout in a
cargo-compatible format (see example below). - If any
Severity::Errordiagnostics exist,anyhow::bail!with a summary, returning exit code 1.
Output example:
error (node 'intro'): node references unknown target 'missing-node'warning: node 'orphan' is unreachable from the start nodeThis format is intentionally similar to compiler output so it can be read by editor integrations or CI log parsers.
new — scaffolding
scaffold_presentation
Creates a single .json file from a hard-coded template. The template is
constructed via serde_json::json! (not a string template) to guarantee
well-formed JSON output. It includes:
$schemareference to the published JSON Schematitle,author,date(populated from the name andtoday_iso_date())defaultswithlayout: "top"andtransition: "fade"- 4 example nodes: title slide, bullet list, code block, closing node
An existing file at the target path is rejected with anyhow::bail! before
any write, providing safe re-run behavior.
scaffold_project
Creates a named project directory structure:
<name>/├── fireside.json ← project manifest with name and nodes list├── nodes/│ └── main.json ← delegates to scaffold_presentation("main", nodes_dir)└── themes/ ← empty directory, ready for .json/.itermcolors themesThe fireside.json manifest is also built via serde_json::json!. Directory
existence is checked before creation; an existing directory is rejected.
import-theme — import_iterm2_theme
Wraps fireside_tui::design::iterm2::Iterm2Scheme::from_file(). The imported
theme name defaults to the file stem if --name is not provided. The resulting
DesignTokens are converted to ThemeFile JSON and written to
~/.config/fireside/themes/<name>.json.
fonts — list_fonts
Calls into fireside_tui::design::fonts to enumerate monospace system fonts via
font-kit. Prints each discovered family name to stdout, one per line. Intended
to help users identify font names for config.json.
Error handling strategy
main returns anyhow::Result<()>. All errors bubble up through ? with
.context(...) annotations at each step. Clap prints usage errors itself
(to stderr) and exits with code 2. anyhow prints runtime errors as
Error: <message>\nCaused by: <chain> to stderr and exits with code 1.
No unwrap() or expect() is permitted in this crate’s library-facing code;
the rule is relaxed only inside main itself for infallible operations where
Result would be noise.