--- 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-19T18:58:46.219Z sources: - id: openwiki-source-9fec9d1af408501c9c455161 resource: repo://src/app.rs generated: { by: "claude-code", at: "2026-10-19T18:58:46.219Z" } --- # 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:49-70`) 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. | | `Sent { chat_jid, text, is_ai, result }` | send background task | Outcome of a text send. | `handle_event` (`src/app.rs:189-202`) 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:119-147`) 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 `WaHandle` and a clone of the event sender used by background tasks. - `Screen` (`src/app.rs:72-78`): `Connecting` → `Pairing` (QR shown) → `Main`. - `Focus` (`src/app.rs:80-85`): `List` or `Input`. - `ListMode` (`src/app.rs:87-92`): `Chats` or `Contacts`. `App::new` (`src/app.rs:154-183`) 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 so that backlog messages are never auto-answered. `build_ai` (`src/app.rs:729-737`) 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:208-230`): - `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`. - `Contact` → `on_contact`: upsert the contact, reload contacts and chats and refresh the open chat name (`src/app.rs:236-244`). - `Message` → `on_message` (`src/app.rs:251-260`). `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` (see [AI autoreply](../workflows/ai-autoreply.md)). Own JIDs are fetched by `refresh_own_jids` (`src/app.rs:323-330`); the list is empty while the device is not paired, so the refresh is retried later. ## Key bindings `handle_key` (`src/app.rs:395-417`): | 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:758-760`) treats Ctrl only as a command when Alt is not held, because Windows reports AltGr as Ctrl+Alt (needed for `@`, `#`, `€` on the Italian layout). `open_selected` (`src/app.rs:497-511`) loads display name, autoreply flag and the last `MESSAGES_SHOWN` (200) messages, then moves focus to the input. The `reload_*` helpers (`src/app.rs:528-553`) keep `selected` inside the active list. `toggle_autoreply` (`src/app.rs:477-491`) always allows disabling. Enabling goes through `can_enable_autoreply` (`src/app.rs:743-751`), which first requires an AI client and then a private chat. 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:606-609`). - `submit_input` (`src/app.rs:453-461`) ignores blank input or no open chat, clears the input and calls `spawn_send` with `is_ai: false`. - `spawn_send` (`src/app.rs:713-722`) calls `WaHandle::send_text` and emits `Sent`. - `handle_sent` (`src/app.rs:365-389`) 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:616-626`) runs a text completion and emits `AiReply`. - `spawn_vision_request` (`src/app.rs:635-645`) 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:653-666`). When both fail, `combine_errors` joins the two messages. Image bytes stay in memory. - `send_ai_reply_if_enabled` (`src/app.rs:350-359`) 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:762` onward cover `can_enable_autoreply`, `vision_applies`, `combine_errors`, selection clamping/stepping, the toggle helpers and `is_command`. See [Unit tests](../testing/unit-tests.md).