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, configuration reloads, relink outcomes), handles function keys (including F8 relink), the list filter and chat deletion, toggles global and per-chat autoreply, and runs background tasks. tags: [app, state, events, keybindings, tokio, web-panel, relink, config-reload] verified:
9fec9d1af4
resource: repo://src/app.rs
generated: { by: "claude-code", at: "2026-10-23T21:26:24.156Z" }
---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:106-141) 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 | 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 with a reply channel. |
ConfigReloaded(Result<Box<Config>, String>) |
configuration watcher task | A configuration file changed: the reloaded Config or the load error. |
Relinked(Result<(), String>) |
relink task | Outcome of a relink: the new unpaired session started, or the error. |
handle_event (src/app.rs:293-317) 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.
App (src/app.rs:188-239) 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 |
pending_relink |
a first F8 press awaits the second one that confirms the relink |
relinking |
a relink is running; further relink requests are ignored until it ends |
Screen (src/app.rs:151-157): Connecting → Pairing (QR shown) → Main.Focus (src/app.rs:159-164): List or Input.ListMode (src/app.rs:166-171): Chats or Contacts.App::new (src/app.rs:246-286) 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, an empty filter and no pending
deletion or relink, and creates the ReplyGate with the current time, so that
backlog messages are never auto-answered live.
build_ai (src/app.rs:1534-1542) returns no client when the system prompt
(SOUL.md, see Configuration) 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.
The configuration watcher
sends ConfigReloaded when config.toml, SOUL.md or VISION.md change.
apply_reloaded_config (src/app.rs:388-400) keeps the current configuration
and shows Configurazione non ricaricata: <error> when the load failed;
otherwise it replaces config, rebuilds the AI client with build_ai (replies
already running keep the client they were started with) and shows
Configurazione ricaricata, or the build_ai status when the AI is not
configured or unavailable. Later replies therefore use the new prompt, model,
delays and retries without a restart.
handle_wa_event (src/app.rs:406-429):
Qr → switch to Pairing and store the QR payload.Connected → switch to Main, clear the QR, 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:435-443).Message { message, image } → on_message (src/app.rs:450-459).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).
Own JIDs are fetched by refresh_own_jids (src/app.rs:797-804); the list is
empty while the device is not paired (or no session is running during a
relink), so the refresh is retried later (own_jids_known stays false until it
succeeds).
F8 (twice) and the web panel's Relink command replace the WhatsApp session
with a new unpaired one; the full flow is in
WhatsApp relink and pairing. In App:
request_relink (src/app.rs:361-369) cancels a pending chat deletion; the
first F8 sets pending_relink and shows
Cancellare la sessione WhatsApp e rifare l'abbinamento? F8 conferma, Esc annulla,
the second calls start_relink.cancel_pending_relink (src/app.rs:375-381) clears the flag on any other
key and shows Nuovo abbinamento annullato.start_relink (src/app.rs:326-341) is a no-op while relinking;
otherwise it switches to Connecting, clears the QR, shows
Cancellazione della sessione in corso..., resets own_jids_known (the new
pairing may be another account) and spawns a task that runs
WaHandle::relink and sends Relinked. The web command calls it directly,
without the two-press confirmation (the browser asks instead).finish_relink (src/app.rs:348-354) clears relinking and shows
Sessione cancellata: scansiona il nuovo QR or
Nuovo abbinamento fallito: <error>; the screen then follows the new
session's Qr / Connected events.A chat is "enabled" when the global autoreply is on or its per-chat flag
is on (autoreply_enabled, src/app.rs:642-644). ai_setup
(src/app.rs:749-751) returns nothing, and so stops every path, when the AI
client or the system prompt is missing. The full workflow is in
AI autoreply; the state machine in App is:
maybe_autoreply (src/app.rs:468-483) calls
schedule_reply when ReplyGate::should_schedule allows it: the chat is
marked pending and spawn_reply_timer (src/app.rs:1345-1350) 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:501-515): 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.handle_reply_due (src/app.rs:539-545) takes the kept
images and, if the chat is still pending, calls prepare_reply
(src/app.rs:556-568). reply_context (src/app.rs:652-664) 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.handle_images_described (src/app.rs:591-614) stores
each description whose message still exists with
History::save_image_description, sets one status for failures
(Descrizione immagine fallita (N): <first error>) and, when then_reply
holds and the chat is still pending, starts the reply.start_reply (src/app.rs:771-790) 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_reply (src/app.rs:622-636) 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.on_offline_sync_completed (src/app.rs:688-694)
runs catch_up once per process (catch_up_done). catch_up
(src/app.rs:704-724) asks catch_up_chats (src/app.rs:731-743) 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.Commands use function keys (src/app.rs:23-32); handle_key
(src/app.rs:884-929) dispatches them:
| Key | Where | Action |
|---|---|---|
| F10 | any screen | Quit (should_quit = true). |
| F8 | any screen | First press asks to delete the WhatsApp session and pair again; second press confirms (see Relink). |
| Esc | after a first F8 | Cancels the pending relink (the key is consumed). |
| F2 | main screen, any focus | Switch chat list / contact list; selection resets to 0, filter kept. |
| F3 | main screen | Toggle autoreply for the focused chat: the selected row with list focus, the open chat with input focus. |
| 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. |
F10 and F8 are checked first, on every screen. Any other key cancels a
pending relink; an Esc that does so is consumed, every other key then
continues normally. Non-main screens ignore everything else. 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:1592-1594)
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 repaint the whole terminal (see
Runtime and event loop).
Typing while the list has focus edits filter (handle_list_key,
edit_filter, src/app.rs:937-973). visible_chats / visible_contacts
(src/app.rs:1048-1058) keep the entries whose name contains the filter,
case-insensitively (matches_filter, src/app.rs:1289-1291); selection,
opening, F3 and deletion all work on the filtered list.
request_delete (src/app.rs:992-1009) 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:1016-1031), 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 <name> eliminata. Any other key (Tab, F2, F3, F4, F8,
list keys, Ctrl chords) cancels the pending deletion with
Eliminazione annullata (cancel_pending_delete, src/app.rs:979-985).
selected_jid (src/app.rs:1221-1232) returns the JID on the selected row of
the active filtered list (chat or contact), or nothing when it is empty.
open_selected (src/app.rs:1205-1219) uses it to load display name,
per-chat autoreply flag and the last MESSAGES_SHOWN (200) messages, then
moves focus to the input. The reload_* helpers (src/app.rs:1249-1274) keep
selected inside the active filtered list.
toggle_autoreply (F3, src/app.rs:1101-1116) picks the target from the
focus — selected_jid with list focus, open_chat with input focus — and
delegates to toggle_chat_autoreply (src/app.rs:1124-1137), which always
allows disabling and updates open_chat_autoreply when the target is the open
chat. Enabling goes through can_enable_autoreply
(src/app.rs:1549-1557), 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:1192-1203) 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:1139-1156) 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:1163-1169): if the JID is
not among the panel's chats the status becomes Chat non trovata,
otherwise toggle_chat_autoreply (same rules as F3);Relink → start_relink (no second confirmation; ignored while a relink runs);Quit → should_quit = true.web_state (src/app.rs:1171-1184) returns the global flag, the status line,
web_chats (src/app.rs:1575-1585) — the TUI chat list in the same order,
restricted to chats that support autoreply — the current screen as
connecting / pairing / main (web_screen, src/app.rs:1563-1569) and,
while a QR payload is stored, the QR code rendered as SVG by web::qr_svg. 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:1335-1338).
submit_input (src/app.rs:1080-1088) ignores blank input or no open chat,
clears the input and calls spawn_send with is_ai: false.spawn_send (src/app.rs:1518-1527) calls WaHandle::send_text and emits
Sent; during a relink no session is running, so sends fail with a send
error.handle_sent (src/app.rs:858-882) 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:1359-1370) 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:1381-1404) 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:812-818) 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:826-839), 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.WaHandle::relink and emits
Relinked.Unit tests at src/app.rs:1596 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; F8 is
not part of that check). Relink, configuration reload and F3 targeting have no
App-level tests. See Unit tests.