app-state.md 26 KB


type: architecture title: Application state and event handling description: How the App struct in src/app.rs owns the application state and routes every AppEvent (keys, WhatsApp events, AI replies, image descriptions, audio transcriptions, reply timers, web panel commands, configuration reloads, relink outcomes), including the image and audio bookkeeping maps and sets, function-key handling, the reply orchestration order and the background tasks. tags: [app, state, events, keybindings, tokio, web-panel, relink, config-reload, transcription] verified:

  • by: openwiki/0.7.2 at: 2026-10-24T16:42:08.550Z sources:
  • id: openwiki-source-9fec9d1af4 resource: repo://src/app.rs
  • id: openwiki-source-ce0c4ea8cd resource: repo://src/history.rs generated: { by: "openwiki/0.7.2", at: "2026-10-24T16:42:08.550Z" } ---

Application state and event handling

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.

Events

AppEvent (src/app.rs:117-161) is the single message type of the main channel:

Variant Producer Meaning
Key(KeyEvent) input thread (wh-input) A key press; release and repeat events are filtered out in main.rs.
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 (the model called the no_reply tool) or an error string.
ImagesDescribed { target, results } image description collector One description (or error) per image, keyed by message id; target is a DescriptionTarget (src/app.rs:163-169) holding the chat and whether to answer it afterwards.
AudiosTranscribed { chat_jid, then_reply, results, images } audio transcription collector One transcription (or error) per audio, keyed by message id; images are the images postponed until the transcriptions are stored and then_reply 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: Ok carries the new message id.
Web(WebRequest) wh-web thread A command from the web control panel with a oneshot 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:319-351) dispatches each variant; after an AiReply it always calls finish_reply to end the chat's pending state, and AudiosTranscribed is routed to handle_audios_transcribed. 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:208-267) 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 (defined in src/autoreply.rs), 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, each with its message id
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)
pending_audios audios received by chats with a scheduled reply, per chat, in arrival order, each with its message id
backlog_audios audios delivered with offline messages, kept until the catch-up runs
transcribing message ids whose audio transcription is in progress (never transcribed 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:171-177): Connecting → Pairing (QR shown) → Main.
  • Focus (src/app.rs:179-184): List or Input.
  • ListMode (src/app.rs:186-191): Chats or Contacts.

App::new (src/app.rs:269-317) takes an AppDeps (src/app.rs:193-199) — the configuration, history, WhatsApp handle and event sender — 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, no open chat, no pending deletion or relink and empty image/audio bookkeeping, and creates the ReplyGate with the current time, so that backlog messages are never auto-answered live.

build_ai (src/app.rs:1874-1887) 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.

Configuration reload

The configuration watcher sends ConfigReloaded when config.toml, SOUL.md or VISION.md change. apply_reloaded_config (src/app.rs:417-434) 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, retries and transcribe_* settings without a restart.

WhatsApp events

handle_wa_event (src/app.rs:436-463):

  • Qr → switch to Pairing and store the QR payload.
  • Connected → switch to Main, clear the QR, status Connesso, refresh own JIDs.
  • Disconnected / Error → copy the text into status only.
  • LoggedOut → back to Connecting, clear the QR, 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:465-477).
  • Message { message, image, audio } → on_message (src/app.rs:479-499).

on_message saves the message and stops immediately when History::save_message reports it was not new, 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, which keeps the message's image and/or audio when the reply is allowed (see AI autoreply).

Own JIDs are fetched by refresh_own_jids (src/app.rs:1030-1042), which replaces the gate's JIDs only when WaHandle::own_jids reports a non-empty set, so own_jids_known stays false and the refresh is retried later while the device is not paired (or no session is running during a relink). It is called on Connected, before scheduling a reply and at the start of the catch-up.

Relink

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:390-403) 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:405-415) clears the flag on any other key and shows Nuovo abbinamento annullato.
  • start_relink (src/app.rs:353-375) is ignored while a relink runs; 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:377-388) 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.

Autoreply orchestration

A chat is "enabled" when the global autoreply is on or its per-chat flag is on (autoreply_enabled, src/app.rs:854-860). ai_setup (src/app.rs:983-989) 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:

flowchart TD
    M["WaEvent::Message with image or audio"] --> MA["maybe_autoreply"]
    MA -->|"should_schedule"| PEND["gate.mark_pending and ReplyDue timer"]
    MA -->|"enabled chat, allowed sender"| KEEP["keep_image and keep_audio"]
    KEEP -->|"chat pending"| PI["pending_images and pending_audios"]
    KEEP -->|"backlog before catch-up"| BI["backlog_images and backlog_audios"]
    KEEP -->|"backlog after catch-up"| LATE["describe_late_image or transcribe_late_audio, no reply"]
    PEND --> RD["ReplyDue"]
    PI --> PREP["prepare_reply"]
    BI --> PREP
    RD --> PREP
    PREP -->|"reply_context returns nothing"| FIN["finish_reply"]
    PREP -->|"audios need transcription"| TR["spawn_transcriptions, images postponed"]
    TR --> AT["AudiosTranscribed"]
    AT -->|"postponed images need description"| DE["spawn_descriptions"]
    AT -->|"nothing left"| SR["start_reply"]
    PREP -->|"no audio, images need description"| DE
    PREP -->|"nothing left"| SR
    DE --> ID["ImagesDescribed"]
    ID --> SR
    SR --> AI["AI request"]
    AI --> AR["AiReply"]
    AR --> FIN
    FIN -->|"newer schedulable message"| PEND
    FIN -->|"otherwise"| DROP["drop kept images and audios"]

How a scheduled reply moves from the WhatsApp message to the AI request, with voice-note transcriptions always completing before image descriptions.

  • Schedule — maybe_autoreply (src/app.rs:501-532) calls schedule_reply when ReplyGate::should_schedule allows it: the chat is marked pending and spawn_reply_timer (src/app.rs:1581-1591) emits ReplyDue after reply_delay_seconds. A chat already pending gets no second timer, so messages arriving meanwhile are answered together. An image or an audio sent by an allowed sender in an enabled chat is kept by keep_image (src/app.rs:544-564) or keep_audio (src/app.rs:583-609): in pending_images/pending_audios for a pending chat, in the backlog maps for an offline message before the catch-up, or processed at once without reply (describe_late_image, transcribe_late_audio, src/app.rs:566-626) after it. A live image or audio always finds its chat pending, because scheduling runs first in maybe_autoreply; audios are kept only when transcription is configured.
  • Reply due — handle_reply_due (src/app.rs:628-641) takes the kept images and audios and, if the chat is still pending, calls prepare_reply (src/app.rs:643-679). reply_context (src/app.rs:862-880) 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 audios still lacking a transcription go first to spawn_transcriptions (src/app.rs:681-715), which passes the postponed images and then_reply: true, and the reply follows AudiosTranscribed; with no audios to transcribe, images still lacking a description go to spawn_descriptions (reply follows on ImagesDescribed), or start_reply runs at once.
  • Transcriptions — handle_audios_transcribed (src/app.rs:772-829) removes the ids from transcribing and appends each transcription to the text of its message as [messaggio vocale] <text> (History::update_message_text), sets one status for failures (Trascrizione vocale fallita (N): <first error>), reloads the open chat so the text appears, then describes the postponed images (whose ImagesDescribed sends the reply) or starts the reply itself.
  • Descriptions — handle_images_described (src/app.rs:738-770) 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 (or ends the pending state if it is no longer wanted).
  • Start — start_reply (src/app.rs:1000-1028) records the last context message in answered_up_to, marks own mentions on every context message, reads the chat name and the stored image descriptions, builds the turn context (self-chat flag, group flag, chat name, descriptions), builds the turns and calls spawn_ai_request.
  • Finish — finish_reply (src/app.rs:831-852) 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 and keeps the images and audios received meanwhile; otherwise it drops the kept images and audios of the chat.
  • Startup catch-up — on_offline_sync_completed (src/app.rs:920-930) runs catch_up once per process (catch_up_done). catch_up (src/app.rs:932-962) drains the backlog maps, asks catch_up_chats (src/app.rs:964-981) 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 that chat's offline images and audios (no reply delay); the status becomes Risposte di recupero: N when N > 0. Backlog media of the other chats is dropped.

Audio transcription path

Voice notes take the same route as images, one step earlier:

  • Gate — transcription_enabled (src/app.rs:717-724) is true only when transcribe_key is set (URL, model and language always have configured defaults). keep_audio returns immediately when it is not, because nothing would consume the kept audio, and audios_to_transcribe (src/app.rs:900-918) yields nothing.
  • Selection — audios_to_transcribe shares select_media_to_describe (src/app.rs:1647-1676, with descriptions_apply, src/app.rs:1850-1857) with images: it keeps the audios whose message is in the AI context, skips those already being transcribed or whose text is no longer the plain voice placeholder (that is, already transcribed), and keeps at most the last MAX_AUDIOS_PER_REPLY (3, src/app.rs:74-77) in arrival order, dropping older ones for good to cap the cost of bursts. The equivalent image limit is MAX_IMAGES_PER_REPLY (5).
  • Spawning — spawn_transcriptions (src/app.rs:681-715) builds a TranscriptionJob from transcribe_url, transcribe_key, transcribe_model and transcribe_language with an empty MIME type, adds the message ids to transcribing and calls spawn_audio_transcriptions (src/app.rs:1786-1830): one task per audio run in parallel, each downloading the media with up to MEDIA_DOWNLOAD_ATTEMPTS (3) attempts, a MEDIA_DOWNLOAD_TIMEOUT_SECONDS (60) timeout each and growing retry waits (download_audio_with_retry, src/app.rs:1758-1784), then calling the OpenAI-compatible transcriber with the downloaded MIME type (transcribe_one_audio, src/app.rs:1832-1848, see Audio transcription); one collector task waits for all of them and emits a single AudiosTranscribed, a failed audio never stopping the others and the bytes staying in memory.
  • Storage and statuses — transcriptions are written by handle_audios_transcribed as [messaggio vocale] <text> (VOICE_NOTE_TEXT, src/app.rs:38-41), so a message is transcribed once; the audio download prefix is Download audio fallito (src/app.rs:70-71) and the failure status is Trascrizione vocale fallita (N): <first error> (transcription_failure_status, src/app.rs:1710-1718).

Ordering invariant

For one reply the media work is strictly ordered: transcriptions first, then image descriptions, then the AI request. prepare_reply never describes images while an audio still needs a transcription; it hands the whole image list to spawn_transcriptions instead, and handle_audios_transcribed describes those postponed images once the transcriptions are stored. A chat's pending state, and therefore its media bookkeeping, is released only by finish_reply, which keeps the kept media only when a newer schedulable message arrived after the answered one — otherwise both maps are emptied.

Key bindings

Commands use function keys (src/app.rs:24-33); handle_key (src/app.rs:1122-1167) 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 (submit_input, src/app.rs:1317-1329).

F10 and F8 are checked first; any other key cancels a pending relink (Esc is then consumed). Non-main screens ignore every remaining key. 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:1932-1939) 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).

List filter

Typing while the list has focus edits filter (handle_list_key, edit_filter, src/app.rs:1169-1211). visible_chats / visible_contacts (src/app.rs:1285-1299) keep the entries whose name contains the filter, case-insensitively (matches_filter, src/app.rs:1526-1532); selection, opening, F3 and deletion all work on the filtered list.

Local chat deletion

request_delete (src/app.rs:1225-1247) 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:1249-1272), which removes the chat from the history (messages, image descriptions and row, hence also its per-chat flag; no WhatsApp call, the contact is kept), takes its pending reply, drops its pending and backlog images, pending and backlog audios 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:1213-1223).

Opening and toggling

selected_jid (src/app.rs:1462-1473) 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:1446-1460) 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:1486-1515) keep selected inside the active filtered list.

toggle_autoreply (F3, src/app.rs:1342-1357) 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:1359-1378), 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:1889-1902), 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:1427-1444) 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:1380-1397) 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:1399-1410): 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:1412-1425) returns the global flag, the toggleable chats (web_chats, src/app.rs:1916-1930 — the TUI chat list in the same order, restricted to chats that support autoreply), the status line, the current screen as connecting / pairing / main (web_screen, src/app.rs:1904-1914) and, while a QR payload is stored, the QR code rendered as SVG by web::qr_svg. See Web control panel.

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:1572-1579).

  • submit_input (src/app.rs:1317-1329) ignores blank input or no open chat, clears the input and calls spawn_send with is_ai: false.
  • spawn_send (src/app.rs:1859-1872) 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:1092-1120) stores a successful send as a from_me message (sender OWN_SENDER_JID = empty, is_ai preserved) and reloads the views; a failure sets Invio fallito: ….
  • spawn_ai_request (src/app.rs:1593-1611) spawns the HTTP request task and a waiter that always sends AiReply, also when the request task fails unexpectedly, so the chat never stays pending.
  • spawn_image_descriptions (src/app.rs:1613-1645) 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.
  • spawn_audio_transcriptions (src/app.rs:1786-1830) does the same for audios, emitting a single AudiosTranscribed that also carries the postponed images (see Audio transcription path).
  • handle_ai_reply (src/app.rs:1044-1056) 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:1058-1077), which sends it only if the chat is still enabled and the owner did not answer by hand meanwhile (owner_answered_meanwhile, src/app.rs:1079-1090); otherwise it shows Risposta AI scartata: autoreply disattivato or Risposta AI scartata: hai già risposto.
  • The relink task (see Relink) runs WaHandle::relink and emits Relinked.

Tests

Unit tests at src/app.rs:1941 onward cover can_enable_autoreply (private, self-chat LID, groups allowed; broadcasts/newsletters and missing AI refused), web_chats filtering and order, descriptions_apply, select_media_to_describe (last five images and last three audios, skip known and out-of-context, empty when not enabled), arrived_after, manual_reply_after, description_failure_status, transcription_failure_status, the voice-note text, 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, the transcription handlers and F3 targeting have no App-level tests. See Unit tests.