|
|
@@ -1,15 +1,15 @@
|
|
|
---
|
|
|
type: architecture
|
|
|
title: Application state and event handling
|
|
|
-description: How the App struct in src/app.rs holds UI state, routes AppEvent values, handles keys, ingests messages and runs background tasks.
|
|
|
-tags: [app, state, events, keybindings, tokio]
|
|
|
+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:
|
|
|
- by: openwiki/0.7.2
|
|
|
- at: 2026-10-19T19:53:56.480Z
|
|
|
+ at: 2026-10-20T20:27:06.515Z
|
|
|
sources:
|
|
|
- id: openwiki-source-9fec9d1af408501c9c455161
|
|
|
resource: repo://src/app.rs
|
|
|
-generated: { by: "claude-code", at: "2026-10-19T19:53:56.480Z" }
|
|
|
+generated: { by: "claude-code", at: "2026-10-20T20:27:06.515Z" }
|
|
|
---
|
|
|
|
|
|
# Application state and event handling
|
|
|
@@ -18,12 +18,12 @@ generated: { by: "claude-code", at: "2026-10-19T19:53:56.480Z" }
|
|
|
[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 and WhatsApp
|
|
|
-activity.
|
|
|
+[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:56-79`) is the single message type of the main channel:
|
|
|
+`AppEvent` (`src/app.rs:73-98`) is the single message type of the main channel:
|
|
|
|
|
|
| Variant | Producer | Meaning |
|
|
|
|---|---|---|
|
|
|
@@ -33,39 +33,41 @@ activity.
|
|
|
| `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](../integrations/web-control-panel.md) with a reply channel. |
|
|
|
|
|
|
-`handle_event` (`src/app.rs:206-220`) dispatches each variant. Only fatal
|
|
|
+`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.
|
|
|
|
|
|
## State
|
|
|
|
|
|
-`App` (`src/app.rs:128-158`) exposes UI fields read by the renderer: `screen`,
|
|
|
-`qr_code`, `focus`, `list_mode`, `chats`, `contacts`, `selected`, `open_chat`,
|
|
|
-`open_chat_name`, `open_chat_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, the `WaHandle` and a clone of the event sender used by
|
|
|
-background tasks.
|
|
|
-
|
|
|
-- `Screen` (`src/app.rs:81-87`): `Connecting` → `Pairing` (QR shown) → `Main`.
|
|
|
-- `Focus` (`src/app.rs:89-94`): `List` or `Input`.
|
|
|
-- `ListMode` (`src/app.rs:96-101`): `Chats` or `Contacts`.
|
|
|
-
|
|
|
-`App::new` (`src/app.rs:165-199`) builds the AI client with `build_ai`, loads
|
|
|
-chats and contacts from history, starts on `Connecting` with list focus in
|
|
|
-chat mode, 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:875-883`) returns no client when `system_prompt` is
|
|
|
+`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.
|
|
|
|
|
|
## WhatsApp events
|
|
|
|
|
|
-`handle_wa_event` (`src/app.rs:226-251`):
|
|
|
+`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.
|
|
|
@@ -74,9 +76,9 @@ with the `AI non disponibile` prefix.
|
|
|
- `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:257-265`).
|
|
|
+ refresh the open chat name (`src/app.rs:288-296`).
|
|
|
- `Message { message, image, addressed_to }` → `on_message`
|
|
|
- (`src/app.rs:273-282`).
|
|
|
+ (`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
|
|
|
@@ -85,95 +87,156 @@ open conversation and then go through `maybe_autoreply` together with the
|
|
|
`addressed_to` JIDs (mentions/quotes) used by the group cooldown (see
|
|
|
[AI autoreply](../workflows/ai-autoreply.md)).
|
|
|
|
|
|
-Own JIDs are fetched by `refresh_own_jids` (`src/app.rs:456-463`); the list is
|
|
|
+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).
|
|
|
|
|
|
## Autoreply triggers
|
|
|
|
|
|
-Three paths start an AI reply, all through `start_reply`
|
|
|
-(`src/app.rs:403-416`), which records the reply in the gate (cancelling any
|
|
|
+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:426-449`) to build the turns and spawn the AI
|
|
|
-task. `ai_setup` (`src/app.rs:393-395`) returns nothing, and so stops every
|
|
|
+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.
|
|
|
|
|
|
-- **Live message** — `maybe_autoreply` (`src/app.rs:293-315`) replies when
|
|
|
+- **Live message** — `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:749-755`) sleeps until then and emits `CooldownElapsed`.
|
|
|
-- **Deferred reply** — `handle_cooldown_elapsed` (`src/app.rs:324-332`)
|
|
|
+ (`src/app.rs:991-997`) sleeps until then and emits `CooldownElapsed`.
|
|
|
+- **Deferred reply** — `handle_cooldown_elapsed` (`src/app.rs:356-364`)
|
|
|
consumes the pending mark with `take_pending`; if it was still pending and
|
|
|
- autoreply is still enabled it sends one text-only reply (no image).
|
|
|
-- **Startup catch-up** — `on_offline_sync_completed` (`src/app.rs:338-344`)
|
|
|
+ the chat is still enabled it sends one text-only reply (no image).
|
|
|
+- **Startup catch-up** — `on_offline_sync_completed` (`src/app.rs:378-384`)
|
|
|
runs `catch_up` once per process (`catch_up_done`). `catch_up`
|
|
|
- (`src/app.rs:353-369`) asks `catch_up_chats` (`src/app.rs:376-387`) for the
|
|
|
- autoreply-enabled chats 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.
|
|
|
+ (`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.
|
|
|
|
|
|
## Key bindings
|
|
|
|
|
|
-`handle_key` (`src/app.rs:528-550`):
|
|
|
+Commands use function keys (`src/app.rs:22-29`); `handle_key`
|
|
|
+(`src/app.rs:573-603`) dispatches them:
|
|
|
|
|
|
| Key | Where | Action |
|
|
|
|---|---|---|
|
|
|
-| Ctrl+C | any screen | Quit (`should_quit = true`). |
|
|
|
-| Ctrl+A | main screen | Toggle autoreply for the open chat. |
|
|
|
+| 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. |
|
|
|
-| F2 | main screen | Switch chat list / contact list; selection resets to 0. |
|
|
|
-| Up / Down | list focus | Move selection, clamped to the list. |
|
|
|
+| 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 Ctrl+C. `is_command`
|
|
|
-(`src/app.rs:905-907`) treats Ctrl only as a command 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)).
|
|
|
+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](runtime-and-event-loop.md)).
|
|
|
+
|
|
|
+### List filter
|
|
|
+
|
|
|
+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.
|
|
|
+
|
|
|
+### Local chat deletion
|
|
|
+
|
|
|
+`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`).
|
|
|
+
|
|
|
+### Opening and toggling
|
|
|
+
|
|
|
+`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>`.
|
|
|
|
|
|
-`open_selected` (`src/app.rs:630-644`) loads display name, autoreply flag and
|
|
|
-the last `MESSAGES_SHOWN` (200) messages, then moves focus to the input. The
|
|
|
-`reload_*` helpers (`src/app.rs:661-686`) keep `selected` inside the active list.
|
|
|
+`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.
|
|
|
|
|
|
-`toggle_autoreply` (`src/app.rs:610-624`) always allows disabling. Enabling
|
|
|
-goes through `can_enable_autoreply` (`src/app.rs:890-898`), 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`.
|
|
|
+## Web panel requests
|
|
|
+
|
|
|
+`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](../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:739-742`).
|
|
|
+shutdown (`src/app.rs:981-984`).
|
|
|
|
|
|
-- `submit_input` (`src/app.rs:586-594`) ignores blank input or no open chat,
|
|
|
+- `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:859-868`) calls `WaHandle::send_text` and emits
|
|
|
+- `spawn_send` (`src/app.rs:1101-1110`) calls `WaHandle::send_text` and emits
|
|
|
`Sent`.
|
|
|
-- `handle_sent` (`src/app.rs:498-522`) stores a successful send as a
|
|
|
+- `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:762-772`) runs a text completion and emits
|
|
|
+- `spawn_ai_request` (`src/app.rs:1004-1014`) runs a text completion and emits
|
|
|
`AiReply`.
|
|
|
-- `spawn_vision_request` (`src/app.rs:781-791`) runs one task that downloads
|
|
|
+- `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:799-812`). When both
|
|
|
- fail, `combine_errors` joins the two messages. Image bytes stay in memory.
|
|
|
-- `send_ai_reply_if_enabled` (`src/app.rs:483-492`) re-checks the persisted
|
|
|
- autoreply flag before sending an AI reply, discarding it with
|
|
|
- `Risposta AI scartata: autoreply disattivato` if the user turned it off
|
|
|
- meanwhile.
|
|
|
+ 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.
|
|
|
|
|
|
## Tests
|
|
|
|
|
|
-Unit tests at `src/app.rs:909` onward cover `can_enable_autoreply` (private,
|
|
|
+Unit tests at `src/app.rs:1167` onward cover `can_enable_autoreply` (private,
|
|
|
self-chat LID, groups allowed; broadcasts/newsletters and missing AI refused),
|
|
|
-`vision_applies`, `combine_errors`, selection clamping/stepping, the toggle
|
|
|
-helpers and `is_command`. See [Unit tests](../testing/unit-tests.md).
|
|
|
+`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](../testing/unit-tests.md).
|