app-state.md 15 KB


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-9fec9d1af4 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 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: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.
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.

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) 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).

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; 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): <first error>) 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).

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 <name>? 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 <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: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 <name>.

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.

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.