--- type: architecture title: Application state and event handling description: How the App struct in src/app.rs holds UI state, routes AppEvent values, handles keys, ingests messages and runs background tasks. tags: [app, state, events, keybindings, tokio] verified: - by: openwiki/0.7.2 at: 2026-10-19T19:53:56.480Z sources: - id: openwiki-source-9fec9d1af408501c9c455161 resource: repo://src/app.rs generated: { by: "claude-code", at: "2026-10-19T19:53:56.480Z" } --- # Application state and event handling `src/app.rs` owns the whole application state. The event loop described in [Runtime and event loop](runtime-and-event-loop.md) feeds every input into `App::handle_event`, and the [terminal UI](terminal-ui.md) renders the public fields of `App` after each event. `App` is the only component that mutates the [history store](../concepts/history-store.md) in response to user and WhatsApp activity. ## Events `AppEvent` (`src/app.rs:56-79`) is the single message type of the main channel: | Variant | Producer | Meaning | |---|---|---| | `Key(KeyEvent)` | input thread | A key press (release/repeat events are filtered out upstream). | | `Redraw` | input thread | Terminal resized; nothing to change, just redraw. | | `Wa(WaEvent)` | WhatsApp forwarder | An event from the [WhatsApp client](../integrations/whatsapp-client.md). | | `AiReply { chat_jid, result }` | AI background task | Generated reply text, or an error string. | | `CooldownElapsed { chat_jid }` | cooldown timer task | The cooldown of a chat with a deferred reply has ended. | | `Sent { chat_jid, text, is_ai, result }` | send background task | Outcome of a text send. | `handle_event` (`src/app.rs:206-220`) dispatches each variant. Only fatal history (SQLite) errors propagate as `Err`; every other failure is turned into a `status` text shown in the footer. ## State `App` (`src/app.rs:128-158`) exposes UI fields read by the renderer: `screen`, `qr_code`, `focus`, `list_mode`, `chats`, `contacts`, `selected`, `open_chat`, `open_chat_name`, `open_chat_autoreply`, `messages`, `input`, `status` and `should_quit`. Private fields hold the `History`, the `Config`, an optional `AiClient`, the autoreply `ReplyGate`, the `own_jids_known` and `catch_up_done` flags, the `WaHandle` and a clone of the event sender used by background tasks. - `Screen` (`src/app.rs:81-87`): `Connecting` → `Pairing` (QR shown) → `Main`. - `Focus` (`src/app.rs:89-94`): `List` or `Input`. - `ListMode` (`src/app.rs:96-101`): `Chats` or `Contacts`. `App::new` (`src/app.rs:165-199`) builds the AI client with `build_ai`, loads chats and contacts from history, starts on `Connecting` with list focus in chat mode, and creates the `ReplyGate` with the current time and the `Cooldowns { private, group }` taken from `cooldown_seconds` and `group_cooldown_seconds`, so that backlog messages are never auto-answered live. `build_ai` (`src/app.rs:875-883`) returns no client when `system_prompt` is missing (status `STATUS_AI_NOT_CONFIGURED`) and reports a client build error with the `AI non disponibile` prefix. ## WhatsApp events `handle_wa_event` (`src/app.rs:226-251`): - `Qr` → switch to `Pairing` and store the QR payload. - `Connected` → switch to `Main`, status `Connesso`, refresh own JIDs. - `Disconnected` / `Error` → update `status` only. - `LoggedOut` → back to `Connecting`, status `Sessione scollegata`. - `OfflineSyncCompleted` → `on_offline_sync_completed`, the startup catch-up (see below). - `Contact` → `on_contact`: upsert the contact, reload contacts and chats and refresh the open chat name (`src/app.rs:257-265`). - `Message { message, image, addressed_to }` → `on_message` (`src/app.rs:273-282`). `on_message` saves the message and stops immediately when `History::save_message` reports a duplicate, so history sync replays never trigger UI reloads or autoreplies twice. New messages reload the chat list, the open conversation and then go through `maybe_autoreply` together with the `addressed_to` JIDs (mentions/quotes) used by the group cooldown (see [AI autoreply](../workflows/ai-autoreply.md)). Own JIDs are fetched by `refresh_own_jids` (`src/app.rs:456-463`); the list is empty while the device is not paired, so the refresh is retried later (`own_jids_known` stays false until it succeeds). ## Autoreply triggers Three paths start an AI reply, all through `start_reply` (`src/app.rs:403-416`), which records the reply in the gate (cancelling any pending deferral), loads the context with `History::messages_since(chat_jid, context_start(now), CONTEXT_MESSAGES)` and calls `spawn_reply` (`src/app.rs:426-449`) to build the turns and spawn the AI task. `ai_setup` (`src/app.rs:393-395`) returns nothing, and so stops every path, when the AI client or the system prompt is missing. - **Live message** — `maybe_autoreply` (`src/app.rs:293-315`) replies when `ReplyGate::should_reply` allows it. Otherwise, if `ReplyGate::deferral_due` returns a due time (private chats blocked only by the cooldown), the chat is marked pending and `spawn_cooldown_timer` (`src/app.rs:749-755`) sleeps until then and emits `CooldownElapsed`. - **Deferred reply** — `handle_cooldown_elapsed` (`src/app.rs:324-332`) consumes the pending mark with `take_pending`; if it was still pending and autoreply is still enabled it sends one text-only reply (no image). - **Startup catch-up** — `on_offline_sync_completed` (`src/app.rs:338-344`) runs `catch_up` once per process (`catch_up_done`). `catch_up` (`src/app.rs:353-369`) asks `catch_up_chats` (`src/app.rs:376-387`) for the autoreply-enabled chats whose latest stored message passes `ReplyGate::catch_up_due` within `catch_up_seconds`, starts a text-only reply for each and sets the status `Risposte di recupero: N` when N > 0. ## Key bindings `handle_key` (`src/app.rs:528-550`): | Key | Where | Action | |---|---|---| | Ctrl+C | any screen | Quit (`should_quit = true`). | | Ctrl+A | main screen | Toggle autoreply for the open chat. | | Tab | main screen | Switch focus between list and input. | | F2 | main screen | Switch chat list / contact list; selection resets to 0. | | Up / Down | list focus | Move selection, clamped to the list. | | Enter | list focus | Open the selected chat or contact. | | characters / Backspace | input focus | Edit the input line. | | Esc | input focus | Return focus to the list. | | Enter | input focus | Send the trimmed input to the open chat. | Non-main screens ignore everything except Ctrl+C. `is_command` (`src/app.rs:905-907`) treats Ctrl only as a command when Alt is not held, because Windows reports AltGr as Ctrl+Alt (needed for `@`, `#`, `€` on the Italian layout). Switching list mode with F2 also makes the event loop clear the terminal (see [Runtime and event loop](runtime-and-event-loop.md)). `open_selected` (`src/app.rs:630-644`) loads display name, autoreply flag and the last `MESSAGES_SHOWN` (200) messages, then moves focus to the input. The `reload_*` helpers (`src/app.rs:661-686`) keep `selected` inside the active list. `toggle_autoreply` (`src/app.rs:610-624`) always allows disabling. Enabling goes through `can_enable_autoreply` (`src/app.rs:890-898`), which first requires an AI client and then a chat accepted by `supports_autoreply` (private chats and groups; broadcasts, status and newsletters are refused with `Autoreply disponibile solo nelle chat private e nei gruppi`). The flag is persisted with `History::set_autoreply`. ## Sending and background tasks All network work runs in tokio tasks that report back through the event channel; `send_event` ignores a closed channel because that only happens on shutdown (`src/app.rs:739-742`). - `submit_input` (`src/app.rs:586-594`) ignores blank input or no open chat, clears the input and calls `spawn_send` with `is_ai: false`. - `spawn_send` (`src/app.rs:859-868`) calls `WaHandle::send_text` and emits `Sent`. - `handle_sent` (`src/app.rs:498-522`) stores a successful send as a `from_me` message (with `is_ai` preserved) and reloads the views; a failure sets `Invio fallito: …`. - `spawn_ai_request` (`src/app.rs:762-772`) runs a text completion and emits `AiReply`. - `spawn_vision_request` (`src/app.rs:781-791`) runs one task that downloads the image and calls the vision model, falling back to the text courtesy turns on failure (`complete_with_fallback`, `src/app.rs:799-812`). When both fail, `combine_errors` joins the two messages. Image bytes stay in memory. - `send_ai_reply_if_enabled` (`src/app.rs:483-492`) re-checks the persisted autoreply flag before sending an AI reply, discarding it with `Risposta AI scartata: autoreply disattivato` if the user turned it off meanwhile. ## Tests Unit tests at `src/app.rs:909` onward cover `can_enable_autoreply` (private, self-chat LID, groups allowed; broadcasts/newsletters and missing AI refused), `vision_applies`, `combine_errors`, selection clamping/stepping, the toggle helpers and `is_command`. See [Unit tests](../testing/unit-tests.md).