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:
9fec9d1af4
resource: repo://src/app.rs
generated: { by: "claude-code", at: "2026-10-20T20:27:06.515Z" }
---src/app.rs owns the whole application state. The event loop described in
Runtime and event loop feeds every input into
App::handle_event, and the terminal UI renders the public
fields of App after each event. App is the only component that mutates the
history store in response to user, WhatsApp
and web panel activity.
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. |
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 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.
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.
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).
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).
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.
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.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).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.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).
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.
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 <name>? 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 <name> 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).
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 <name>.
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.
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.
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.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.