type: architecture title: Runtime and event loop description: Process startup in src/main.rs (including the --version flag and the build version embedded by build.rs), the tokio runtime, the two event channels, the input thread, the WhatsApp forwarder, the optional web panel thread and the draw/recv TUI loop. tags: [runtime, tokio, event-loop, ratatui, crossterm, web-panel, version] verified:
36f054f1f3
resource: repo://build.rsb55a21a31e
resource: repo://src/main.rs
generated: { by: "claude-code", at: "2026-10-21T21:49:12.291Z" }
---The binary (wh) is a single-process terminal app. src/main.rs wires the
modules together and runs one serialised event loop: every state change goes
through App::handle_event, so App needs no locking.
build.rs runs git rev-parse --short HEAD at compile time and exports the
result as the WH_GIT_HASH environment variable (unknown when git is
missing, fails or prints nothing); it asks cargo to rebuild when .git/HEAD,
.git/refs or .git/packed-refs change (build.rs:14-33). main.rs
combines it into BUILD_VERSION = <CARGO_PKG_VERSION> (<hash>)
(src/main.rs:24-25), which the terminal UI shows at the
bottom right.
main (src/main.rs:36-64) runs on the multi-threaded tokio runtime
(#[tokio::main]). If any command-line argument is --version or -V
(wants_version, src/main.rs:66-72) it only prints wh <BUILD_VERSION>
and exits, without reading the configuration or touching the terminal.
Otherwise it:
Config::load (config.toml, SOUL.md,
vision.md) and creates the data directory (ensure_data_dir). See
Configuration and data.history_db_path().tokio::sync::mpsc channels: one for AppEvent
and one for WaEvent.whatsapp::start(session_db_path, wa_tx).start_web_panel (src/main.rs:88-97): nothing happens when
web_port is unset, otherwise web::start(port, app_tx) binds the port.App from AppDeps { config, history, wa, events }.Any failure before the TUI starts aborts with an anyhow context such as
cannot load configuration, cannot open history database,
cannot start WhatsApp client or cannot start web server on port <port>
(for example when the port is already in use).
crossterm (blocking) whatsapp-rust bot tiny_http (blocking) tokio tasks in App
"wh-input" thread Translator task "wh-web" thread (AI, image description,
| | | send, reply timers)
| Key/Redraw | WaEvent | Web(WebRequest) |
v v v | AiReply / Sent /
app_tx <------ WhatsApp forwarder (Wa(..)) <-------+ | ImagesDescribed /
^ | ReplyDue
+-----------------------------------------------------------------------+
|
app_rx --> event_loop: draw ui::render -> recv -> app.handle_event
src/main.rs:74-86): a tokio task that wraps each
WaEvent in AppEvent::Wa and stops when either channel side closes.wh-web, see Web control panel):
a blocking HTTP server on its own OS thread; each API request is sent as
AppEvent::Web and the thread waits on a oneshot channel for the state
snapshot that App sends back after handling it.src/main.rs:99-134): a named OS thread (wh-input)
because crossterm::event::read blocks. On a read error it sends
WaEvent::Error("terminal input error: …") and stops instead of spinning.src/main.rs:136-147): only KeyEventKind::Press keys
become AppEvent::Key (Windows also emits release and repeat events);
Resize becomes AppEvent::Redraw; everything else is dropped.App (AI completions, image downloads and
descriptions, sends, reply-delay timers) hold a clone of the sender and
report with AiReply / ImagesDescribed / Sent / ReplyDue.run_tui (src/main.rs:149-158) calls ratatui::init, runs the loop and
always calls ratatui::restore, so raw mode and the alternate screen are
left even on errors.
event_loop (src/main.rs:160-184) repeats while !app.should_quit:
draw a frame with ui::render, await the next event, and
pass it to app.handle_event. The loop also ends cleanly when the event
channel closes. Draw errors and fatal handle_event errors end the process.
should_quit is set by F10 or by a quit request from the web panel.
Before dispatching, the loop records list_mode_before = app.list_mode; if
the event changed the list mode (F2 switches chats/contacts), it calls
terminal.clear() to force a full repaint, so wide glyphs from the previous
list cannot leave stale cells behind.
Because rendering happens only after an event, the screen is redrawn on key presses, resizes, WhatsApp events, web panel requests and background task results — there is no timer tick.
src/main.rs tests check that only --version / -V request the version
and that BUILD_VERSION starts with the package version and names a commit
in parentheses. See Unit tests.