--- type: architecture title: Application state and event handling description: How the App struct in src/app.rs holds UI state, routes AppEvent values (keys, WhatsApp, AI, timers, web panel), handles function keys, the list filter and chat deletion, toggles global and per-chat autoreply, and runs background tasks. tags: [app, state, events, keybindings, tokio, web-panel] verified: - by: openwiki/0.7.2 at: 2026-10-20T20:27:06.515Z sources: - id: openwiki-source-9fec9d1af408501c9c455161 resource: repo://src/app.rs generated: { by: "claude-code", at: "2026-10-20T20:27:06.515Z" } --- # 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, WhatsApp and [web panel](../integrations/web-control-panel.md) activity. ## Events `AppEvent` (`src/app.rs:73-98`) 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. | | `Web(WebRequest)` | `wh-web` thread | A command from the [web control panel](../integrations/web-control-panel.md) with a reply channel. | `handle_event` (`src/app.rs:236-251`) 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:147-184`) exposes UI fields read by the renderer: `screen`, `qr_code`, `focus`, `list_mode`, `chats`, `contacts`, `selected`, `filter`, `open_chat`, `open_chat_name`, `open_chat_autoreply`, `global_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, `pending_delete` (the chat awaiting a deletion confirmation), the `WaHandle` and a clone of the event sender used by background tasks. - `Screen` (`src/app.rs:100-106`): `Connecting` → `Pairing` (QR shown) → `Main`. - `Focus` (`src/app.rs:108-113`): `List` or `Input`. - `ListMode` (`src/app.rs:115-120`): `Chats` or `Contacts`. `App::new` (`src/app.rs:191-229`) builds the AI client with `build_ai`, loads chats, contacts and the persisted global autoreply flag from history, starts on `Connecting` with list focus in chat mode and an empty filter, 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:1117-1125`) 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:257-282`): - `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:288-296`). - `Message { message, image, addressed_to }` → `on_message` (`src/app.rs:304-313`). `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:497-504`); 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 A chat is "enabled" when the global autoreply is on **or** its per-chat flag is on (`autoreply_enabled`, `src/app.rs:370-372`); every path below uses this check. Three paths start an AI reply, all through `start_reply` (`src/app.rs:444-457`), 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:467-490`) to build the turns and spawn the AI task. `ai_setup` (`src/app.rs:434-436`) returns nothing, and so stops every path, when the AI client or the system prompt is missing. - **Live message** — `maybe_autoreply` (`src/app.rs:324-346`) 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:991-997`) sleeps until then and emits `CooldownElapsed`. - **Deferred reply** — `handle_cooldown_elapsed` (`src/app.rs:356-364`) consumes the pending mark with `take_pending`; if it was still pending and the chat is still enabled it sends one text-only reply (no image). - **Startup catch-up** — `on_offline_sync_completed` (`src/app.rs:378-384`) runs `catch_up` once per process (`catch_up_done`). `catch_up` (`src/app.rs:393-409`) asks `catch_up_chats` (`src/app.rs:416-428`) for the enabled chats (all chats when the global autoreply is on) 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 Commands use function keys (`src/app.rs:22-29`); `handle_key` (`src/app.rs:573-603`) dispatches them: | Key | Where | Action | |---|---|---| | F10 | any screen | Quit (`should_quit = true`). | | F2 | main screen, any focus | Switch chat list / contact list; selection resets to 0, filter kept. | | F3 | main screen, any focus | Toggle autoreply for the open chat. | | F4 | main screen, any focus | Toggle the global autoreply ("AI tutte"). | | Tab | main screen | Switch focus between list and input. | | Up / Down | list focus | Move selection, clamped to the filtered list. | | Enter | list focus | Open the selected chat or contact. | | characters / Backspace | list focus | Edit the list filter; selection resets to 0. | | Esc | list focus | Clear the filter (or cancel a pending deletion). | | Delete (Canc) | list focus, chat mode | Ask to delete the selected chat; a second Delete confirms. | | 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 F10. Ctrl chords are swallowed (they cancel a pending deletion and do nothing else) so they never type into the input or the filter; `is_command` (`src/app.rs:1163-1165`) treats Ctrl as a command only 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)). ### List filter Typing while the list has focus edits `filter` (`handle_list_key`, `edit_filter`, `src/app.rs:610-638`). `visible_chats` / `visible_contacts` (`src/app.rs:710-720`) keep the entries whose name contains the filter, case-insensitively (`matches_filter`, `src/app.rs:935-937`); selection, opening and deletion all work on the filtered list. ### Local chat deletion `request_delete` (`src/app.rs:657-674`) works only in chat mode: the first Delete stores the selected chat in `pending_delete` and shows `Eliminare la chat ? Canc conferma, Esc annulla`; a second Delete on the same chat calls `confirm_delete` (`src/app.rs:681-693`), which removes the chat's messages and row from the history (no WhatsApp call), drops any deferred reply for it, closes it if open and shows `Chat eliminata`. Any other key (Tab, F2, F3, F4, list keys, Ctrl chords) cancels the pending deletion with `Eliminazione annullata` (`cancel_pending_delete`, `src/app.rs:644-650`). ### Opening and toggling `open_selected` (`src/app.rs:862-878`) loads display name, per-chat autoreply flag and the last `MESSAGES_SHOWN` (200) messages of the selected visible entry, then moves focus to the input. The `reload_*` helpers (`src/app.rs:895-920`) keep `selected` inside the active filtered list. `toggle_autoreply` (F3, `src/app.rs:768-773`) delegates to `toggle_chat_autoreply` (`src/app.rs:781-794`), which always allows disabling. Enabling goes through `can_enable_autoreply` (`src/app.rs:1132-1140`), 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` and the status shows `Autoreply ON|OFF per `. `toggle_global_autoreply` (F4, `src/app.rs:845-856`) refuses to enable without an AI client (`STATUS_AI_NOT_CONFIGURED`), persists the flag with `History::set_global_autoreply`, updates `global_autoreply` and shows `Autoreply globale ON|OFF`. Disabling is always allowed. ## Web panel requests `handle_web_request` (`src/app.rs:801-812`) applies a `WebCommand` and then always sends back a `WebState` snapshot on the request's oneshot channel: - `State` → nothing, just the snapshot; - `ToggleGlobal` → `toggle_global_autoreply` (same rules as F4); - `ToggleChat(jid)` → `toggle_web_chat` (`src/app.rs:819-825`): if the JID is not among the panel's chats the status becomes `Chat non trovata`, otherwise `toggle_chat_autoreply` (same rules as F3); - `Quit` → `should_quit = true`. `web_state` (`src/app.rs:831-837`) returns the global flag, the status line and `web_chats` (`src/app.rs:1146-1156`): the TUI chat list in the same order, restricted to chats that support autoreply. See [Web control panel](../integrations/web-control-panel.md). ## 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:981-984`). - `submit_input` (`src/app.rs:742-750`) ignores blank input or no open chat, clears the input and calls `spawn_send` with `is_ai: false`. - `spawn_send` (`src/app.rs:1101-1110`) calls `WaHandle::send_text` and emits `Sent`. - `handle_sent` (`src/app.rs:540-564`) 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:1004-1014`) runs a text completion and emits `AiReply`. - `spawn_vision_request` (`src/app.rs:1023-1033`) 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:1041-1054`). When both fail, `combine_errors` joins the two messages. Image bytes stay in memory. - `send_ai_reply_if_enabled` (`src/app.rs:525-534`) re-checks `autoreply_enabled` (global or per chat) before sending an AI reply, discarding it with `Risposta AI scartata: autoreply disattivato` if both were turned off meanwhile. ## Tests Unit tests at `src/app.rs:1167` onward cover `can_enable_autoreply` (private, self-chat LID, groups allowed; broadcasts/newsletters and missing AI refused), `web_chats` filtering and order, `vision_applies`, `combine_errors`, `matches_filter`, selection clamping/stepping, the toggle helpers, `is_command` and the fixed function-key codes (F2, F3, F4, F10). See [Unit tests](../testing/unit-tests.md).