--- 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 replies, image descriptions, reply 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-21T21:49:12.291Z sources: - id: openwiki-source-9fec9d1af408501c9c455161 resource: repo://src/app.rs generated: { by: "claude-code", at: "2026-10-21T21:49:12.291Z" } --- # 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:90-121`) 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 | `Reply::Text`, `Reply::NoReply` (model called the `no_reply` tool) or an error string. | | `ImagesDescribed { target, results }` | image description task | One description (or error) per image of a chat, keyed by message id; `target` says whether to answer the chat afterwards. | | `ReplyDue { chat_jid }` | reply timer task | The reply delay of a chat has elapsed. | | `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:267-283`) dispatches each variant; after an `AiReply` it always calls `finish_reply` to end the chat's pending state. 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:169-215`) 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 WhatsApp handle and a clone of the event sender, plus the reply bookkeeping: | Field | Purpose | |---|---| | `pending_images` | images received by chats with a scheduled reply, per chat, in arrival order | | `backlog_images` | images delivered with offline messages, kept until the catch-up runs | | `describing` | message ids whose image description is in progress (never described twice) | | `answered_up_to` | per chat, the id of the last message in the context of the reply in progress | | `own_jids_known`, `catch_up_done` | one-shot flags for own JIDs and the startup catch-up | | `pending_delete` | the chat awaiting a deletion confirmation | - `Screen` (`src/app.rs:131-137`): `Connecting` → `Pairing` (QR shown) → `Main`. - `Focus` (`src/app.rs:139-144`): `List` or `Input`. - `ListMode` (`src/app.rs:146-151`): `Chats` or `Contacts`. `App::new` (`src/app.rs:222-260`) 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, so that backlog messages are never auto-answered live. `build_ai` (`src/app.rs:1393-1401`) returns no client when the system prompt (`SOUL.md`, see [Configuration](../operations/configuration-and-data.md)) is missing — status `AI non configurata: imposta openai_api_key in config.toml e il prompt in SOUL.md` — and reports a client build error with the `AI non disponibile` prefix. ## WhatsApp events `handle_wa_event` (`src/app.rs:289-312`): - `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:318-326`). - `Message { message, image }` → `on_message` (`src/app.rs:333-342`). `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:680-687`); 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 orchestration A chat is "enabled" when the global autoreply is on **or** its per-chat flag is on (`autoreply_enabled`, `src/app.rs:525-527`). `ai_setup` (`src/app.rs:632-634`) returns nothing, and so stops every path, when the AI client or the system prompt is missing. The full workflow is in [AI autoreply](../workflows/ai-autoreply.md); the state machine in `App` is: - **Schedule** — `maybe_autoreply` (`src/app.rs:351-366`) calls `schedule_reply` when `ReplyGate::should_schedule` allows it: the chat is marked pending and `spawn_reply_timer` (`src/app.rs:1204-1209`) emits `ReplyDue` after `reply_delay_seconds`. A chat already pending gets no second timer, so messages arriving meanwhile are answered together. An image sent by an allowed sender in an enabled chat is kept by `keep_image` (`src/app.rs:384-398`): in `pending_images` for a pending chat, in `backlog_images` for an offline image before the catch-up, or described at once without reply (`describe_late_image`) after it. - **Reply due** — `handle_reply_due` (`src/app.rs:422-428`) takes the kept images and, if the chat is still pending, calls `prepare_reply` (`src/app.rs:439-451`). `reply_context` (`src/app.rs:535-547`) returns nothing when the autoreply was disabled, the AI is missing, the chat is empty or its last message was written by hand by the owner; then the pending state ends. Otherwise images still lacking a description go to `spawn_descriptions` (reply follows on `ImagesDescribed`), or `start_reply` runs at once. - **Descriptions** — `handle_images_described` (`src/app.rs:474-497`) stores each description whose message still exists with `History::save_image_description`, sets one status for failures (`Descrizione immagine fallita (N): `) and, when `then_reply` holds and the chat is still pending, starts the reply. - **Start** — `start_reply` (`src/app.rs:654-673`) records the last context message in `answered_up_to`, marks own mentions, reads the chat name and the stored image descriptions, builds the turns and calls `spawn_ai_request`. - **Finish** — `finish_reply` (`src/app.rs:505-519`) clears the pending state and `answered_up_to`; if a newer message that passes `should_schedule` arrived after the answered one, it schedules a new reply (keeping its images), otherwise it drops the kept images. - **Startup catch-up** — `on_offline_sync_completed` (`src/app.rs:571-577`) runs `catch_up` once per process (`catch_up_done`). `catch_up` (`src/app.rs:587-607`) asks `catch_up_chats` (`src/app.rs:614-626`) 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`, marks each pending and calls `prepare_reply` with its offline images (no reply delay); the status becomes `Risposte di recupero: N` when N > 0. ## Key bindings Commands use function keys (`src/app.rs:23-32`); `handle_key` (`src/app.rs:774-804`) 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 by one row, clamped to the filtered list. | | PageUp / PageDown | list focus | Move selection by 10 rows (`LIST_PAGE_ROWS`), clamped. | | Home / End | list focus | Jump to the first / last row. | | 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:1434-1441`) 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:812-848`). `visible_chats` / `visible_contacts` (`src/app.rs:923-933`) keep the entries whose name contains the filter, case-insensitively (`matches_filter`, `src/app.rs:1148-1150`); selection, opening and deletion all work on the filtered list. ### Local chat deletion `request_delete` (`src/app.rs:867-884`) 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:891-906`), which removes the chat from the history (messages, image descriptions and row; no WhatsApp call), drops its pending reply, kept images and answered snapshot, 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:854-860`). ### Opening and toggling `open_selected` (`src/app.rs:1075-1091`) 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:1108-1133`) keep `selected` inside the active filtered list. `toggle_autoreply` (F3, `src/app.rs:981-986`) delegates to `toggle_chat_autoreply` (`src/app.rs:994-1007`), which always allows disabling. Enabling goes through `can_enable_autoreply` (`src/app.rs:1408-1416`), 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:1058-1069`) 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:1014-1025`) 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:1032-1038`): 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:1044-1050`) returns the global flag, the status line and `web_chats` (`src/app.rs:1422-1432`): 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:1194-1197`). - `submit_input` (`src/app.rs:955-963`) ignores blank input or no open chat, clears the input and calls `spawn_send` with `is_ai: false`. - `spawn_send` (`src/app.rs:1377-1386`) calls `WaHandle::send_text` and emits `Sent`. - `handle_sent` (`src/app.rs:741-765`) 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:1218-1229`) runs the text completion in one task and waits for it in another, so `AiReply` is always emitted — even when the request task panics — and the chat never stays pending. - `spawn_image_descriptions` (`src/app.rs:1240-1263`) runs one task per image in parallel (download with up to 3 attempts and a 60 s timeout each, then `AiClient::describe_image`) and a collector task that emits a single `ImagesDescribed`; a failed image does not stop the others and the bytes stay in memory. - `handle_ai_reply` (`src/app.rs:695-701`) shows `AI: nessuna risposta necessaria` for `Reply::NoReply`, `Errore AI: …` for an error, and passes text to `send_ai_reply_if_enabled` (`src/app.rs:709-722`), which sends it only if the chat is still enabled and the owner did not answer by hand meanwhile (`owner_answered_meanwhile`); otherwise it shows `Risposta AI scartata: autoreply disattivato` or `Risposta AI scartata: hai già risposto`. ## Tests Unit tests at `src/app.rs:1443` onward cover `can_enable_autoreply` (private, self-chat LID, groups allowed; broadcasts/newsletters and missing AI refused), `web_chats` filtering and order, `vision_applies`, `select_images_to_describe` (last five, skip known and out-of-context), `arrived_after`, `manual_reply_after`, `description_failure_status`, `matches_filter`, selection clamping/stepping and page steps, the toggle helpers, `is_command` and the fixed function-key codes (F2, F3, F4, F10). See [Unit tests](../testing/unit-tests.md).