Browse Source

feat: add global AI autoreply feature with configuration and UI updates

- Implemented global AI autoreply functionality that can be toggled on or off.
- Updated configuration to include `ai_retries` for failed AI requests.
- Enhanced the history module to manage global autoreply state in the database.
- Modified the app structure to support global autoreply and its integration with chat handling.
- Updated UI to reflect global autoreply status in the chat list and footer.
- Added tests for new features including global autoreply and AI retries.
- Created a README file with build instructions and usage details.
softword 2 days ago
parent
commit
aa9ecd558d
8 changed files with 668 additions and 47 deletions
  1. 8 0
      Cargo.toml
  2. 42 0
      README.md
  3. 115 5
      src/ai.rs
  4. 242 24
      src/app.rs
  5. 31 1
      src/config.rs
  6. 101 1
      src/history.rs
  7. 104 12
      src/ui.rs
  8. 25 4
      src/whatsapp.rs

+ 8 - 0
Cargo.toml

@@ -22,6 +22,14 @@ dirs = "7"
 base64 = "0.23"
 chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
 
+[profile.release]
+# Remove symbols and debug info from the binary.
+strip = true
+# Cross-crate link-time optimization: drops unused code and shrinks the binary.
+lto = true
+# Single codegen unit: better optimization and smaller size, slower release builds.
+codegen-units = 1
+
 [lints.rust]
 # MSVC link.exe prints an informational "Creazione della libreria ... wh.lib/wh.exp" line because the binary exports symbols; it is not actionable.
 linker_messages = "allow"

+ 42 - 0
README.md

@@ -0,0 +1,42 @@
+# wh
+
+Terminal WhatsApp client (ratatui) with per-chat and global AI autoreply via an OpenAI-compatible API.
+
+## Build
+
+```sh
+cargo build --release
+```
+
+## Cross-compile: Linux x86_64 static (musl)
+
+Built from Windows with cargo-zigbuild. Zig provides the C cross-compiler and linker for the bundled C code (SQLite, aws-lc-sys, ring).
+
+One-time prerequisites:
+
+```
+rustup target add x86_64-unknown-linux-musl
+winget install -e --id zig.zig      # or: pip install ziglang
+cargo install --locked cargo-zigbuild
+```
+
+Build:
+
+```
+cargo zigbuild --release --target x86_64-unknown-linux-musl
+```
+
+Output: `target/x86_64-unknown-linux-musl/release/wh`
+
+Verify on Linux:
+
+```sh
+file wh   # ELF 64-bit x86-64, statically linked
+chmod +x wh && ./wh
+```
+
+### Data directory
+
+On Linux, config and databases live in `~/.config/wh/` (`config.toml`, `session.db`, `history.db`); on Windows in `%APPDATA%\wh`.
+
+Copy `session.db` from Windows to reuse the linked session instead of scanning the QR again.

+ 115 - 5
src/ai.rs

@@ -34,6 +34,8 @@ const LOG_ERROR_LABEL: &str = "ERRORE";
 const DATA_URL_BASE64_MARKER: &str = ";base64,";
 /// Prefix of a data URL.
 const DATA_URL_PREFIX: &str = "data:";
+/// Base wait, in seconds, between attempts: the wait before retry N is N times this value.
+const RETRY_BASE_DELAY_SECONDS: u64 = 2;
 
 /// Author of a single conversation turn sent to the model.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -74,8 +76,29 @@ pub struct AiClient {
     vision_model: Option<String>,
     /// File receiving one record per request; `None` when `ai_log` is disabled.
     log_path: Option<PathBuf>,
+    /// Number of retries after a failed attempt.
+    retries: u32,
 }
 
+/// Non-2xx response of the AI service, kept typed so retries can inspect the status.
+#[derive(Debug)]
+struct StatusError {
+    status: reqwest::StatusCode,
+    body: String,
+}
+
+impl fmt::Display for StatusError {
+    /// Formats the error as the user-visible message (status code and truncated body).
+    ///
+    /// Inputs: formatter. Output: formatting result.
+    /// Side effects: writes to the formatter only.
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(formatter, "servizio AI ha risposto {}: {}", self.status, self.body)
+    }
+}
+
+impl std::error::Error for StatusError {}
+
 impl fmt::Debug for AiClient {
     /// Formats the client for debugging, deliberately omitting the API key.
     ///
@@ -88,6 +111,7 @@ impl fmt::Debug for AiClient {
             .field("model", &self.model)
             .field("vision_model", &self.vision_model)
             .field("log_path", &self.log_path)
+            .field("retries", &self.retries)
             .finish_non_exhaustive()
     }
 }
@@ -142,6 +166,7 @@ impl AiClient {
             model: config.openai_model.clone(),
             vision_model: config.openai_vision_model.clone(),
             log_path: config.ai_log.then(|| config.ai_log_path()),
+            retries: config.ai_retries,
         })
     }
 
@@ -151,9 +176,9 @@ impl AiClient {
     /// Output: the first choice's message content, trimmed; an error on
     /// transport failure, non-2xx status (status code and truncated body, never
     /// the key), malformed JSON or empty reply.
-    /// Side effects: performs one HTTPS POST request.
+    /// Side effects: performs up to `1 + retries` HTTPS POST requests.
     pub async fn complete(&self, turns: &[ChatTurn]) -> anyhow::Result<String> {
-        self.send_body(&build_request_body(&self.model, turns)).await
+        self.send_with_retry(&build_request_body(&self.model, turns)).await
     }
 
     /// Tells whether a vision model is configured.
@@ -171,7 +196,7 @@ impl AiClient {
     /// Output: the reply text; an error if no vision model is configured, the
     /// image is empty or larger than `MAX_IMAGE_BYTES`, or on any failure
     /// described in `complete`. Errors never contain the image data.
-    /// Side effects: performs one HTTPS POST request.
+    /// Side effects: performs up to `1 + retries` HTTPS POST requests.
     pub async fn complete_with_image(
         &self,
         turns: &[ChatTurn],
@@ -183,10 +208,33 @@ impl AiClient {
             .ok_or_else(|| anyhow!(MISSING_VISION_MODEL_MESSAGE))?;
         validate_image(image)?;
 
-        self.send_body(&build_vision_request_body(model, turns, image))
+        self.send_with_retry(&build_vision_request_body(model, turns, image))
             .await
     }
 
+    /// Sends a prepared request body, retrying transient failures.
+    ///
+    /// Inputs: `request_body`, the JSON payload for the chat completions endpoint.
+    /// Output: the reply text of the first successful attempt, or the error of the
+    /// last attempt (returned immediately when not retryable or retries are exhausted).
+    /// Side effects: performs up to `1 + retries` HTTPS POST requests (each one
+    /// logged when the AI log is enabled); sleeps `retry_delay` before each retry.
+    async fn send_with_retry(&self, request_body: &Value) -> anyhow::Result<String> {
+        let mut attempt = 0;
+
+        loop {
+            let outcome = self.send_body(request_body).await;
+
+            match &outcome {
+                Err(error) if attempt < self.retries && is_retryable(error) => {
+                    attempt += 1;
+                    tokio::time::sleep(retry_delay(attempt)).await;
+                }
+                _ => return outcome,
+            }
+        }
+    }
+
     /// Sends a prepared request body, extracts the reply text and, when the AI
     /// log is enabled, records the request together with its outcome.
     ///
@@ -230,7 +278,7 @@ impl AiClient {
             .context("lettura della risposta del servizio AI fallita")?;
 
         if !status.is_success() {
-            bail!("servizio AI ha risposto {status}: {}", truncate_chars(&text));
+            return Err(StatusError { status, body: truncate_chars(&text) }.into());
         }
         let body: Value = serde_json::from_str(&text).with_context(|| {
             format!(
@@ -243,6 +291,32 @@ impl AiClient {
     }
 }
 
+/// Tells whether a failed attempt is worth retrying.
+///
+/// Inputs: the `error` of the attempt.
+/// Output: `false` for client errors (4xx) other than 429; `true` for 429, 5xx
+/// and any non-status failure (timeouts, connection errors, malformed or empty
+/// replies are considered transient).
+/// Side effects: none.
+fn is_retryable(error: &anyhow::Error) -> bool {
+    match error.downcast_ref::<StatusError>() {
+        Some(failure) => {
+            failure.status == reqwest::StatusCode::TOO_MANY_REQUESTS
+                || !failure.status.is_client_error()
+        }
+        None => true,
+    }
+}
+
+/// Computes the wait before a retry.
+///
+/// Inputs: `attempt`, the 1-based number of the retry about to be made.
+/// Output: `RETRY_BASE_DELAY_SECONDS` times `attempt` (linear backoff).
+/// Side effects: none.
+fn retry_delay(attempt: u32) -> Duration {
+    Duration::from_secs(RETRY_BASE_DELAY_SECONDS * u64::from(attempt))
+}
+
 /// Builds the JSON request body for the chat completions endpoint.
 ///
 /// Inputs: `model` name and the conversation `turns`.
@@ -471,9 +545,15 @@ mod tests {
             catch_up_seconds: 7200,
             data_dir: PathBuf::from("data"),
             openai_vision_model: None,
+            ai_retries: crate::config::DEFAULT_AI_RETRIES,
         }
     }
 
+    /// Wraps a status code and body into an `anyhow::Error` like `request_reply` does.
+    fn status_error(status: reqwest::StatusCode) -> anyhow::Error {
+        StatusError { status, body: "b".to_string() }.into()
+    }
+
     /// Builds a client, optionally with a vision model configured.
     fn test_client(vision_model: Option<&str>) -> AiClient {
         let mut config = test_config(Some(TEST_API_KEY));
@@ -730,6 +810,36 @@ mod tests {
         assert_eq!(content, "one\ntwo\n");
     }
 
+    #[test]
+    fn is_retryable_rejects_client_errors_except_429() {
+        assert!(!is_retryable(&status_error(reqwest::StatusCode::BAD_REQUEST)));
+        assert!(!is_retryable(&status_error(reqwest::StatusCode::UNAUTHORIZED)));
+        assert!(is_retryable(&status_error(reqwest::StatusCode::TOO_MANY_REQUESTS)));
+        assert!(is_retryable(&status_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR)));
+        assert!(is_retryable(&status_error(reqwest::StatusCode::SERVICE_UNAVAILABLE)));
+    }
+
+    #[test]
+    fn is_retryable_accepts_generic_errors() {
+        assert!(is_retryable(&anyhow!("x")));
+    }
+
+    #[test]
+    fn status_error_message_is_unchanged() {
+        let error = status_error(reqwest::StatusCode::SERVICE_UNAVAILABLE);
+
+        assert_eq!(
+            error.to_string(),
+            "servizio AI ha risposto 503 Service Unavailable: b"
+        );
+    }
+
+    #[test]
+    fn retry_delay_grows_linearly() {
+        assert_eq!(retry_delay(1), Duration::from_secs(2));
+        assert_eq!(retry_delay(2), Duration::from_secs(4));
+    }
+
     #[test]
     fn truncate_chars_limits_length_on_char_boundary() {
         let long = "è".repeat(ERROR_BODY_MAX_CHARS + 10);

+ 242 - 24
src/app.rs

@@ -21,8 +21,9 @@ pub const MESSAGES_SHOWN: usize = 200;
 /// Key code of the function key that toggles the list mode.
 const LIST_MODE_FUNCTION_KEY: u8 = 2;
 /// Character that, combined with Ctrl, quits the application.
-const QUIT_CHAR: char = 'c';
-/// Character that, combined with Ctrl, toggles the autoreply of the open chat.
+const QUIT_CHAR: char = 'q';
+/// Character that, combined with Ctrl, toggles the autoreply of the open chat; with Ctrl+Shift
+/// it toggles the global autoreply.
 const AUTOREPLY_CHAR: char = 'a';
 /// Sender jid stored for messages written by the user (own account).
 const OWN_SENDER_JID: &str = "";
@@ -36,6 +37,8 @@ const STATUS_LOGGED_OUT: &str = "Sessione scollegata";
 /// Status shown when the autoreply cannot be enabled without an AI client.
 const STATUS_AI_NOT_CONFIGURED: &str =
     "AI non configurata: imposta openai_api_key e system_prompt in config.toml";
+/// Prefix of the status shown when the global autoreply is toggled (followed by ON/OFF).
+const STATUS_GLOBAL_AUTOREPLY_PREFIX: &str = "Autoreply globale";
 /// Prefix of the status shown when the AI client cannot be built.
 const STATUS_AI_UNAVAILABLE_PREFIX: &str = "AI non disponibile";
 /// Prefix of the status shown when the AI request fails.
@@ -52,6 +55,16 @@ const STATUS_FALLBACK_ERROR_PREFIX: &str = "Fallback cortesia fallito";
 const STATUS_HISTORY_ERROR_PREFIX: &str = "Errore storico";
 /// Prefix of the status shown when a message cannot be sent.
 const STATUS_SEND_FAILED_PREFIX: &str = "Invio fallito";
+/// Status shown when a pending chat deletion is cancelled by another key.
+const STATUS_DELETE_CANCELLED: &str = "Eliminazione annullata";
+/// Prefix of the status asking to confirm the deletion of a chat (followed by `<name>?`).
+const STATUS_DELETE_CONFIRM_PREFIX: &str = "Eliminare la chat";
+/// Hint appended to the deletion confirmation question.
+const STATUS_DELETE_CONFIRM_HINT: &str = "Canc conferma, Esc annulla";
+/// Prefix of the status shown once a chat was deleted locally (followed by `<name>` and the suffix).
+const STATUS_CHAT_DELETED_PREFIX: &str = "Chat";
+/// Suffix of the status shown once a chat was deleted locally.
+const STATUS_CHAT_DELETED_SUFFIX: &str = "eliminata";
 
 /// Event delivered to `App::handle_event` by the main loop and by background tasks.
 #[derive(Debug)]
@@ -135,10 +148,15 @@ pub struct App {
     pub contacts: Vec<Contact>,
     /// Index in the active list.
     pub selected: usize,
+    /// Text typed while the list has focus; filters chat and contact names, case-insensitive;
+    /// kept when switching list with F2.
+    pub filter: String,
     /// Jid of the open chat.
     pub open_chat: Option<String>,
     pub open_chat_name: String,
     pub open_chat_autoreply: bool,
+    /// AI autoreply enabled for every chat, regardless of per-chat flags.
+    pub global_autoreply: bool,
     /// Open chat messages, ascending, last `MESSAGES_SHOWN`.
     pub messages: Vec<StoredMessage>,
     pub input: String,
@@ -153,6 +171,8 @@ pub struct App {
     own_jids_known: bool,
     /// Whether the catch-up after the offline sync already ran in this process.
     catch_up_done: bool,
+    /// Jid of the chat awaiting the second Delete press that confirms its local deletion.
+    pending_delete: Option<String>,
     wa: WaHandle,
     events: UnboundedSender<AppEvent>,
 }
@@ -167,6 +187,7 @@ impl App {
         let (ai, status) = build_ai(&config);
         let chats = history.list_chats()?;
         let contacts = history.list_contacts()?;
+        let global_autoreply = history.is_global_autoreply()?;
         let cooldowns = Cooldowns {
             private: config.cooldown_seconds,
             group: config.group_cooldown_seconds,
@@ -180,9 +201,11 @@ impl App {
             chats,
             contacts,
             selected: 0,
+            filter: String::new(),
             open_chat: None,
             open_chat_name: String::new(),
             open_chat_autoreply: false,
+            global_autoreply,
             messages: Vec::new(),
             input: String::new(),
             status,
@@ -193,6 +216,7 @@ impl App {
             gate: ReplyGate::new(now_unix(), cooldowns),
             own_jids_known: false,
             catch_up_done: false,
+            pending_delete: None,
             wa,
             events,
         })
@@ -281,8 +305,8 @@ impl App {
         self.maybe_autoreply(message, image, addressed_to)
     }
 
-    /// Purpose: start an AI completion if the gate allows an autoreply to `message`, or schedule
-    /// one for the end of the cooldown.
+    /// Purpose: start an AI completion if the gate allows an autoreply to `message` (autoreply
+    /// enabled globally or for its chat), or schedule one for the end of the cooldown.
     /// Inputs: `message`, the newly stored message; `image`, the reference to its image, if any;
     /// `addressed_to`, the raw JIDs the message mentions or quotes (shortens the group cooldown).
     /// Output: `Ok(())`, or a history error.
@@ -303,7 +327,7 @@ impl App {
             self.refresh_own_jids();
         }
         let now = now_unix();
-        let enabled = self.history.is_autoreply(&message.chat_jid)?;
+        let enabled = self.autoreply_enabled(&message.chat_jid)?;
         if self.gate.should_reply(message, enabled, addressed_to, now) {
             return self.start_reply(ai, &prompt, &message.chat_jid, image, now);
         }
@@ -318,11 +342,12 @@ impl App {
     /// Inputs: `chat_jid`, the chat whose timer fired.
     /// Output: `Ok(())`, or a history error.
     /// Side effects: consumes the pending deferral; if it was still pending, the autoreply is
-    /// still enabled and the AI is configured, records the reply in the gate and spawns a task
-    /// that sends `AiReply`. The reply is text-only: an image appears as its placeholder in the
-    /// context. Nothing happens when a live reply already cancelled the deferral.
+    /// still enabled (globally or per chat) and the AI is configured, records the reply in the
+    /// gate and spawns a task that sends `AiReply`. The reply is text-only: an image appears as
+    /// its placeholder in the context. Nothing happens when a live reply already cancelled the
+    /// deferral.
     fn handle_cooldown_elapsed(&mut self, chat_jid: &str) -> Result<()> {
-        if !self.gate.take_pending(chat_jid) || !self.history.is_autoreply(chat_jid)? {
+        if !self.gate.take_pending(chat_jid) || !self.autoreply_enabled(chat_jid)? {
             return Ok(());
         }
         let Some((ai, prompt)) = self.ai_setup() else {
@@ -331,6 +356,14 @@ impl App {
         self.start_reply(ai, &prompt, chat_jid, None, now_unix())
     }
 
+    /// Purpose: tell whether the AI autoreply applies to a chat.
+    /// Inputs: `chat_jid`, the chat to check.
+    /// Output: `true` when the global autoreply or the per-chat flag is on, or a history error.
+    /// Side effects: reads the history (skipped when the global autoreply is on).
+    fn autoreply_enabled(&self, chat_jid: &str) -> Result<bool> {
+        Ok(self.global_autoreply || self.history.is_autoreply(chat_jid)?)
+    }
+
     /// Purpose: run the catch-up once per process when the offline messages have been delivered.
     /// Inputs: none.
     /// Output: `Ok(())`, or a history error.
@@ -370,12 +403,13 @@ impl App {
 
     /// Purpose: find the chats whose last message deserves a catch-up reply.
     /// Inputs: `now`, unix seconds.
-    /// Output: the JIDs of the chats with the autoreply enabled whose latest stored message
-    /// passes `ReplyGate::catch_up_due`, or a history error.
+    /// Output: the JIDs of the chats with the autoreply enabled (globally or per chat) whose
+    /// latest stored message passes `ReplyGate::catch_up_due`, or a history error.
     /// Side effects: reads the history.
     fn catch_up_chats(&self, now: i64) -> Result<Vec<String>> {
         let mut due = Vec::new();
-        for chat in self.history.list_chats()?.into_iter().filter(|chat| chat.autoreply) {
+        let chats = self.history.list_chats()?;
+        for chat in chats.into_iter().filter(|chat| self.global_autoreply || chat.autoreply) {
             let Some(last) = self.history.recent_messages(&chat.jid, 1)?.pop() else {
                 continue;
             };
@@ -465,7 +499,7 @@ impl App {
     /// Purpose: handle the outcome of an AI completion.
     /// Inputs: `chat_jid`, the chat to answer; `result`, the generated text or an error text.
     /// Output: none.
-    /// Side effects: on success, if the autoreply is still enabled, spawns a send task; if it was
+    /// Side effects: on success, if the autoreply is still enabled (globally or per chat), spawns a send task; if it was
     /// disabled meanwhile or the lookup fails, nothing is sent and the status explains why; on
     /// failure sets the error status.
     fn handle_ai_reply(&mut self, chat_jid: String, result: Result<String, String>) {
@@ -475,13 +509,14 @@ impl App {
         }
     }
 
-    /// Purpose: send a generated AI reply only if the chat still has the autoreply enabled.
+    /// Purpose: send a generated AI reply only if the chat still has the autoreply enabled
+    /// (globally or per chat).
     /// Inputs: `chat_jid`, the chat to answer; `text`, the generated reply.
     /// Output: none.
     /// Side effects: spawns a send task when enabled; otherwise sets the discard or history
     /// error status and sends nothing.
     fn send_ai_reply_if_enabled(&mut self, chat_jid: String, text: String) {
-        match self.history.is_autoreply(&chat_jid) {
+        match self.autoreply_enabled(&chat_jid) {
             Ok(true) => {
                 let outgoing = OutgoingMessage { chat_jid, text, is_ai: true };
                 spawn_send(self.wa.clone(), self.events.clone(), outgoing);
@@ -535,13 +570,20 @@ impl App {
             return Ok(());
         }
         if ctrl {
+            self.cancel_pending_delete();
+            if is_global_toggle(&key) {
+                return self.toggle_global_autoreply();
+            }
             return match key.code {
                 KeyCode::Char(AUTOREPLY_CHAR) => self.toggle_autoreply(),
                 _ => Ok(()),
             };
         }
         match (key.code, self.focus) {
-            (KeyCode::Tab, _) => self.focus = toggled_focus(self.focus),
+            (KeyCode::Tab, _) => {
+                self.cancel_pending_delete();
+                self.focus = toggled_focus(self.focus);
+            }
             (KeyCode::F(LIST_MODE_FUNCTION_KEY), _) => self.toggle_list_mode(),
             (code, Focus::List) => return self.handle_list_key(code),
             (code, Focus::Input) => self.handle_input_key(code),
@@ -551,18 +593,121 @@ impl App {
 
     /// Purpose: handle a key while the list has the focus.
     /// Inputs: `code`, the key code.
-    /// Output: `Ok(())`, or a history error when opening a chat.
-    /// Side effects: moves the selection or opens the selected chat.
+    /// Output: `Ok(())`, or a history error when opening or deleting a chat.
+    /// Side effects: any key but Delete cancels a pending deletion (Esc is then consumed); moves
+    /// the selection, edits the filter, opens the selected chat or asks/confirms a chat deletion.
     fn handle_list_key(&mut self, code: KeyCode) -> Result<()> {
+        if code == KeyCode::Delete {
+            return self.request_delete();
+        }
+        if self.cancel_pending_delete() && code == KeyCode::Esc {
+            return Ok(());
+        }
         match code {
             KeyCode::Up => self.selected = step_selection(self.selected, self.active_len(), -1),
             KeyCode::Down => self.selected = step_selection(self.selected, self.active_len(), 1),
             KeyCode::Enter => return self.open_selected(),
+            KeyCode::Char(c) => self.edit_filter(|filter| filter.push(c)),
+            KeyCode::Backspace => self.edit_filter(|filter| {
+                filter.pop();
+            }),
+            KeyCode::Esc => self.edit_filter(String::clear),
             _ => {}
         }
         Ok(())
     }
 
+    /// Purpose: apply an edit to the list filter.
+    /// Inputs: `edit`, the mutation to apply to `filter`.
+    /// Output: none.
+    /// Side effects: changes `filter` and moves the selection to the first row.
+    fn edit_filter(&mut self, edit: impl FnOnce(&mut String)) {
+        edit(&mut self.filter);
+        self.selected = 0;
+    }
+
+    /// Purpose: drop a pending chat deletion, if any.
+    /// Inputs: none.
+    /// Output: `true` if a deletion was pending.
+    /// Side effects: clears `pending_delete` and, when it was set, shows the cancellation status.
+    fn cancel_pending_delete(&mut self) -> bool {
+        let was_pending = self.pending_delete.take().is_some();
+        if was_pending {
+            self.status = STATUS_DELETE_CANCELLED.to_owned();
+        }
+        was_pending
+    }
+
+    /// Purpose: handle Delete on the list: ask for confirmation, or confirm on the second press.
+    /// Inputs: none (uses the selected visible chat and `pending_delete`).
+    /// Output: `Ok(())`, or a history error when the deletion is confirmed.
+    /// Side effects: sets `pending_delete` and `status`; on confirmation deletes the chat locally.
+    /// Does nothing in contacts mode or without a selected chat.
+    fn request_delete(&mut self) -> Result<()> {
+        if self.list_mode != ListMode::Chats {
+            return Ok(());
+        }
+        let Some((jid, name)) = self
+            .visible_chats()
+            .get(self.selected)
+            .map(|chat| (chat.jid.clone(), chat.name.clone()))
+        else {
+            return Ok(());
+        };
+        if self.pending_delete.as_deref() == Some(jid.as_str()) {
+            return self.confirm_delete(jid, name);
+        }
+        self.status = format!("{STATUS_DELETE_CONFIRM_PREFIX} {name}? {STATUS_DELETE_CONFIRM_HINT}");
+        self.pending_delete = Some(jid);
+        Ok(())
+    }
+
+    /// Purpose: delete a chat from the local history only (no WhatsApp call).
+    /// Inputs: `jid`, the chat to delete; `name`, its display name for the status.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: deletes the chat from the history, drops its deferred AI reply, closes it if
+    /// open, reloads the chat list and updates `status`; always clears `pending_delete`.
+    fn confirm_delete(&mut self, jid: String, name: String) -> Result<()> {
+        self.pending_delete = None;
+        self.history.delete_chat(&jid)?;
+
+        // The returned flag only tells whether a reply was deferred: nothing to do either way.
+        let _ = self.gate.take_pending(&jid);
+        if self.open_chat.as_deref() == Some(jid.as_str()) {
+            self.close_open_chat();
+        }
+        self.reload_chats()?;
+        self.status = format!("{STATUS_CHAT_DELETED_PREFIX} {name} {STATUS_CHAT_DELETED_SUFFIX}");
+        Ok(())
+    }
+
+    /// Purpose: close the open chat.
+    /// Inputs: none.
+    /// Output: none.
+    /// Side effects: clears `open_chat`, its name, autoreply flag and messages.
+    fn close_open_chat(&mut self) {
+        self.open_chat = None;
+        self.open_chat_name.clear();
+        self.open_chat_autoreply = false;
+        self.messages.clear();
+    }
+
+    /// Purpose: chats whose name matches the current filter.
+    /// Inputs: none.
+    /// Output: the matching chats, in list order.
+    /// Side effects: none.
+    pub fn visible_chats(&self) -> Vec<&ChatSummary> {
+        self.chats.iter().filter(|chat| matches_filter(&chat.name, &self.filter)).collect()
+    }
+
+    /// Purpose: contacts whose name matches the current filter.
+    /// Inputs: none.
+    /// Output: the matching contacts, in list order.
+    /// Side effects: none.
+    pub fn visible_contacts(&self) -> Vec<&Contact> {
+        self.contacts.iter().filter(|contact| matches_filter(&contact.name, &self.filter)).collect()
+    }
+
     /// Purpose: handle a key while the input line has the focus.
     /// Inputs: `code`, the key code.
     /// Output: none.
@@ -596,8 +741,10 @@ impl App {
     /// Purpose: switch between chat list and contact list.
     /// Inputs: none.
     /// Output: none.
-    /// Side effects: changes `list_mode` and resets the selection to the first row.
+    /// Side effects: changes `list_mode`, resets the selection to the first row and cancels a
+    /// pending chat deletion; the filter is kept.
     fn toggle_list_mode(&mut self) {
+        self.cancel_pending_delete();
         self.list_mode = toggled_list_mode(self.list_mode);
         self.selected = 0;
     }
@@ -623,14 +770,35 @@ impl App {
         self.reload_chats()
     }
 
+    /// Purpose: toggle the global AI autoreply, which answers every chat regardless of the
+    /// per-chat flags.
+    /// Inputs: none.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: writes the persistent global flag, updates `global_autoreply` and `status`;
+    /// refuses to enable without an AI client (disabling is always allowed).
+    fn toggle_global_autoreply(&mut self) -> Result<()> {
+        let enable = !self.global_autoreply;
+        if enable && self.ai.is_none() {
+            self.status = STATUS_AI_NOT_CONFIGURED.to_owned();
+            return Ok(());
+        }
+        self.history.set_global_autoreply(enable)?;
+        self.global_autoreply = enable;
+        let state = if enable { "ON" } else { "OFF" };
+        self.status = format!("{STATUS_GLOBAL_AUTOREPLY_PREFIX} {state}");
+        Ok(())
+    }
+
     /// Purpose: open the chat or contact selected in the active list.
     /// Inputs: none.
     /// Output: `Ok(())`, or a history error.
     /// Side effects: loads name, autoreply flag and messages; moves the focus to the input.
     fn open_selected(&mut self) -> Result<()> {
         let jid = match self.list_mode {
-            ListMode::Chats => self.chats.get(self.selected).map(|chat| chat.jid.clone()),
-            ListMode::Contacts => self.contacts.get(self.selected).map(|c| c.jid.clone()),
+            ListMode::Chats => self.visible_chats().get(self.selected).map(|chat| chat.jid.clone()),
+            ListMode::Contacts => {
+                self.visible_contacts().get(self.selected).map(|contact| contact.jid.clone())
+            }
         };
         let Some(jid) = jid else {
             return Ok(());
@@ -645,12 +813,12 @@ impl App {
 
     /// Purpose: number of rows of the active list.
     /// Inputs: none.
-    /// Output: length of the chat or contact list according to `list_mode`.
+    /// Output: length of the visible (filtered) chat or contact list according to `list_mode`.
     /// Side effects: none.
     fn active_len(&self) -> usize {
         match self.list_mode {
-            ListMode::Chats => self.chats.len(),
-            ListMode::Contacts => self.contacts.len(),
+            ListMode::Chats => self.visible_chats().len(),
+            ListMode::Contacts => self.visible_contacts().len(),
         }
     }
 
@@ -694,6 +862,14 @@ fn now_unix() -> i64 {
     chrono::Utc::now().timestamp()
 }
 
+/// Purpose: tell whether a list entry name passes the list filter.
+/// Inputs: `name`, the chat or contact name; `filter`, the text typed by the user.
+/// Output: `true` if `filter` is empty or contained in `name`, ignoring case.
+/// Side effects: none.
+fn matches_filter(name: &str, filter: &str) -> bool {
+    filter.is_empty() || name.to_lowercase().contains(&filter.to_lowercase())
+}
+
 /// Purpose: keep a selection index inside a list.
 /// Inputs: `selected`, the current index; `len`, the list length.
 /// Output: `selected` limited to the last row, or 0 for an empty list.
@@ -906,6 +1082,20 @@ fn is_command(modifiers: KeyModifiers) -> bool {
     modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT)
 }
 
+/// Purpose: tell whether a key event is the Shift variant of the autoreply chord.
+/// Inputs: `key`, the key event (callers check the Ctrl command chord separately).
+/// Output: `true` when the key is `AUTOREPLY_CHAR` (either case) with Shift held; `false` otherwise.
+/// Side effects: none. Terminals may report Ctrl+Shift+A as `'a'` or `'A'` with Shift, so the
+/// character is compared ignoring ASCII case.
+fn is_global_toggle(key: &KeyEvent) -> bool {
+    match key.code {
+        KeyCode::Char(c) => {
+            c.eq_ignore_ascii_case(&AUTOREPLY_CHAR) && key.modifiers.contains(KeyModifiers::SHIFT)
+        }
+        _ => false,
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -957,6 +1147,15 @@ mod tests {
         );
     }
 
+    #[test]
+    fn matches_filter_is_case_insensitive_and_empty_matches_all() {
+        assert!(matches_filter("Mario Rossi", "mar"));
+        assert!(!matches_filter("Mario", "ROSSI"));
+        assert!(matches_filter("Mario Rossi", "ROSSI"));
+        assert!(matches_filter("x", ""));
+        assert!(matches_filter("Àlex", "àl"));
+    }
+
     #[test]
     fn clamped_selection_stays_inside_list() {
         assert_eq!(clamped_selection(5, 3), 2);
@@ -988,4 +1187,23 @@ mod tests {
         assert!(!is_command(KeyModifiers::NONE));
         assert!(is_command(KeyModifiers::CONTROL | KeyModifiers::SHIFT));
     }
+
+    #[test]
+    fn is_global_toggle_accepts_ctrl_shift_a_in_both_cases() {
+        let chord = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
+        assert!(is_global_toggle(&KeyEvent::new(KeyCode::Char('a'), chord)));
+        assert!(is_global_toggle(&KeyEvent::new(KeyCode::Char('A'), chord)));
+    }
+
+    #[test]
+    fn is_global_toggle_rejects_ctrl_only_and_other_chars() {
+        let chord = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
+        assert!(!is_global_toggle(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
+        assert!(!is_global_toggle(&KeyEvent::new(KeyCode::Char('c'), chord)));
+    }
+
+    #[test]
+    fn ctrl_q_is_the_quit_key() {
+        assert_eq!(QUIT_CHAR, 'q');
+    }
 }

+ 31 - 1
src/config.rs

@@ -12,7 +12,10 @@
 //! `group_cooldown_seconds` (non-negative integer, default 300: same, for group messages not
 //! addressed to the user); 0 disables the respective cooldown, `catch_up_seconds`
 //! (non-negative integer, default 7200: after a restart, the last unanswered message of each
-//! private chat is answered once if it is at most this old; 0 disables the catch-up). Unknown keys are ignored. A missing file means
+//! private chat is answered once if it is at most this old; 0 disables the catch-up),
+//! `ai_retries` (non-negative integer, default 2: how many times a failed AI request is
+//! retried, waiting 2s, 4s, ... between attempts; 0 disables retrying; HTTP 4xx errors other
+//! than 429 are never retried). Unknown keys are ignored. A missing file means
 //! all defaults; an unreadable or invalid file is an error. The data directory is fixed and
 //! cannot be changed from the file.
 //!
@@ -36,6 +39,8 @@ pub const DEFAULT_COOLDOWN_SECONDS: i64 = 30;
 pub const DEFAULT_GROUP_COOLDOWN_SECONDS: i64 = 300;
 /// Default maximum age, in seconds, of a message answered by the catch-up after a restart.
 pub const DEFAULT_CATCH_UP_SECONDS: i64 = 7200;
+/// Default number of retries of a failed AI request (0 disables retrying).
+pub const DEFAULT_AI_RETRIES: u32 = 2;
 
 /// Name of the application sub-directory inside the platform config directory.
 const APP_DIR_NAME: &str = "wh";
@@ -83,6 +88,9 @@ pub struct Config {
     /// Maximum age in seconds of a message answered by the catch-up after a restart, from
     /// `catch_up_seconds` (file); default 7200, 0 disables the catch-up.
     pub catch_up_seconds: i64,
+    /// Number of retries of a failed AI request, from `ai_retries` (file); default 2, 0
+    /// disables retrying.
+    pub ai_retries: u32,
     /// Directory holding `config.toml` and the databases: `<platform config dir>/wh`.
     pub data_dir: PathBuf,
 }
@@ -109,6 +117,8 @@ struct FileConfig {
     group_cooldown_seconds: Option<u32>,
     /// Catch-up window after a restart (unsigned: negatives are rejected).
     catch_up_seconds: Option<u32>,
+    /// Retries of a failed AI request (unsigned: negatives are rejected).
+    ai_retries: Option<u32>,
 }
 
 impl fmt::Debug for Config {
@@ -132,6 +142,7 @@ impl fmt::Debug for Config {
             .field("cooldown_seconds", &self.cooldown_seconds)
             .field("group_cooldown_seconds", &self.group_cooldown_seconds)
             .field("catch_up_seconds", &self.catch_up_seconds)
+            .field("ai_retries", &self.ai_retries)
             .field("data_dir", &self.data_dir)
             .finish()
     }
@@ -247,6 +258,7 @@ impl Config {
                 .group_cooldown_seconds
                 .map_or(DEFAULT_GROUP_COOLDOWN_SECONDS, i64::from),
             catch_up_seconds: file.catch_up_seconds.map_or(DEFAULT_CATCH_UP_SECONDS, i64::from),
+            ai_retries: file.ai_retries.unwrap_or(DEFAULT_AI_RETRIES),
             data_dir,
         })
     }
@@ -467,6 +479,24 @@ mod tests {
         assert!(config_from("catch_up_seconds = -1\n").is_err());
     }
 
+    #[test]
+    fn ai_retries_defaults_when_unset() {
+        let cfg = ok_config("");
+        assert_eq!(cfg.ai_retries, DEFAULT_AI_RETRIES);
+        assert!(format!("{cfg:?}").contains("ai_retries"));
+    }
+
+    #[test]
+    fn ai_retries_uses_custom_value_including_zero() {
+        assert_eq!(ok_config("ai_retries = 5\n").ai_retries, 5);
+        assert_eq!(ok_config("ai_retries = 0\n").ai_retries, 0);
+    }
+
+    #[test]
+    fn negative_ai_retries_is_rejected() {
+        assert!(config_from("ai_retries = -1\n").is_err());
+    }
+
     #[test]
     fn ai_log_path_is_under_data_dir() {
         let cfg = ok_config("");

+ 101 - 1
src/history.rs

@@ -1,4 +1,4 @@
-//! SQLite-backed store for message history, chats (with autoreply flag) and contacts.
+//! SQLite-backed store for message history, chats (with autoreply flag), contacts and settings.
 
 use std::path::Path;
 
@@ -8,12 +8,17 @@ use rusqlite::{Connection, OptionalExtension, Row, params};
 const TABLE_MESSAGES: &str = "messages";
 const TABLE_CHATS: &str = "chats";
 const TABLE_CONTACTS: &str = "contacts";
+const TABLE_SETTINGS: &str = "settings";
 const INDEX_MESSAGES_BY_CHAT: &str = "idx_messages_chat_timestamp";
 
 /// Default `chats.last_timestamp` for chats that have no message yet.
 const DEFAULT_LAST_TIMESTAMP: i64 = 0;
 /// Default autoreply state of a chat that was never toggled.
 const DEFAULT_AUTOREPLY: bool = false;
+/// Key of the global autoreply flag in the settings table.
+const SETTING_GLOBAL_AUTOREPLY: &str = "global_autoreply";
+/// Default global autoreply state when the setting was never stored.
+const DEFAULT_GLOBAL_AUTOREPLY: bool = false;
 
 /// Separator between the user part and the server part of a WhatsApp jid.
 const JID_SEPARATOR: char = '@';
@@ -276,6 +281,60 @@ impl History {
 
         Ok(enabled.unwrap_or(DEFAULT_AUTOREPLY))
     }
+
+    /// Purpose: enable or disable the AI autoreply for all chats at once.
+    /// Inputs: the new state.
+    /// Output: `()`.
+    /// Side effects: upserts the global autoreply row of the settings table.
+    pub fn set_global_autoreply(&self, enabled: bool) -> Result<()> {
+        let sql = format!(
+            "INSERT INTO {TABLE_SETTINGS} (key, value) VALUES (?1, ?2) \
+             ON CONFLICT(key) DO UPDATE SET value = excluded.value"
+        );
+
+        self.connection
+            .execute(&sql, params![SETTING_GLOBAL_AUTOREPLY, enabled])
+            .with_context(|| format!("set global autoreply={enabled}"))?;
+
+        Ok(())
+    }
+
+    /// Purpose: tell whether the AI autoreply is enabled globally.
+    /// Inputs: none.
+    /// Output: the stored flag, or the default (`false`) if never set.
+    /// Side effects: none (read only).
+    pub fn is_global_autoreply(&self) -> Result<bool> {
+        let sql = format!("SELECT value FROM {TABLE_SETTINGS} WHERE key = ?1");
+        let enabled: Option<bool> = self
+            .connection
+            .query_row(&sql, params![SETTING_GLOBAL_AUTOREPLY], |row| row.get(0))
+            .optional()
+            .context("read global autoreply")?;
+
+        Ok(enabled.unwrap_or(DEFAULT_GLOBAL_AUTOREPLY))
+    }
+
+    /// Purpose: delete a chat from the local history.
+    /// Inputs: `chat_jid`.
+    /// Output: `()` (also when the chat does not exist).
+    /// Side effects: removes the chat messages and the chat row (including its per-chat
+    /// autoreply flag), atomically; the contact, if any, is kept; a later message recreates the chat.
+    pub fn delete_chat(&self, chat_jid: &str) -> Result<()> {
+        let context = || format!("delete chat {chat_jid}");
+        let delete_messages = format!("DELETE FROM {TABLE_MESSAGES} WHERE chat_jid = ?1");
+        let delete_chat = format!("DELETE FROM {TABLE_CHATS} WHERE jid = ?1");
+        let transaction = self.connection.unchecked_transaction().with_context(context)?;
+
+        transaction
+            .execute(&delete_messages, params![chat_jid])
+            .with_context(context)?;
+        transaction
+            .execute(&delete_chat, params![chat_jid])
+            .with_context(context)?;
+        transaction.commit().with_context(context)?;
+
+        Ok(())
+    }
 }
 
 /// Purpose: build the `CREATE TABLE/INDEX IF NOT EXISTS` script of the whole schema.
@@ -305,6 +364,10 @@ fn schema_sql() -> String {
         CREATE TABLE IF NOT EXISTS {TABLE_CONTACTS} (
             jid  TEXT PRIMARY KEY,
             name TEXT NOT NULL
+        );
+        CREATE TABLE IF NOT EXISTS {TABLE_SETTINGS} (
+            key   TEXT PRIMARY KEY,
+            value INTEGER NOT NULL
         );",
         default_autoreply = i64::from(DEFAULT_AUTOREPLY),
     )
@@ -603,6 +666,19 @@ mod tests {
         assert!(!history.is_autoreply(CHAT_A).unwrap());
     }
 
+    #[test]
+    fn global_autoreply_defaults_to_false_toggles_and_is_independent_of_chats() {
+        let history = History::open_in_memory().unwrap();
+        assert!(!history.is_global_autoreply().unwrap());
+
+        history.set_global_autoreply(true).unwrap();
+        assert!(history.is_global_autoreply().unwrap());
+        assert!(!history.is_autoreply(CHAT_A).unwrap());
+
+        history.set_global_autoreply(false).unwrap();
+        assert!(!history.is_global_autoreply().unwrap());
+    }
+
     #[test]
     fn autoreply_survives_incoming_messages_and_shows_in_chat_list() {
         let history = History::open_in_memory().unwrap();
@@ -615,6 +691,30 @@ mod tests {
         assert_eq!(chats[0].last_timestamp, 10);
     }
 
+    #[test]
+    fn delete_chat_removes_messages_and_row_but_keeps_contact_and_other_chats() {
+        let history = History::open_in_memory().unwrap();
+        history.save_message(&message("a1", CHAT_A, 10)).unwrap();
+        history.save_message(&message("a2", CHAT_A, 20)).unwrap();
+        history.save_message(&message("b1", CHAT_B, 30)).unwrap();
+        history.upsert_contact(&contact(CHAT_A, "Anna")).unwrap();
+        history.set_autoreply(CHAT_A, true).unwrap();
+
+        history.delete_chat(CHAT_A).unwrap();
+
+        let jids: Vec<String> = history
+            .list_chats()
+            .unwrap()
+            .into_iter()
+            .map(|chat| chat.jid)
+            .collect();
+        assert_eq!(jids, [CHAT_B]);
+        assert!(history.recent_messages(CHAT_A, 10).unwrap().is_empty());
+        assert!(!history.recent_messages(CHAT_B, 10).unwrap().is_empty());
+        assert_eq!(history.list_contacts().unwrap(), vec![contact(CHAT_A, "Anna")]);
+        assert!(!history.is_autoreply(CHAT_A).unwrap());
+    }
+
     #[test]
     fn list_contacts_is_ordered_case_insensitive() {
         let history = History::open_in_memory().unwrap();

+ 104 - 12
src/ui.rs

@@ -42,13 +42,21 @@ const AI_ON: &str = "ON";
 const AI_OFF: &str = "OFF";
 /// Marker appended to chat rows whose autoreply is on.
 const AI_ROW_MARKER: &str = " ●AI";
+/// Marker appended to the chats list title when autoreply is on for every chat.
+const AI_GLOBAL_MARKER: &str = "●AI tutte ";
+/// Label shown in the list pane title in front of the active search text.
+const FILTER_LABEL: &str = "Cerca: ";
 /// Symbol drawn in front of the selected list row.
 const HIGHLIGHT_SYMBOL: &str = "> ";
 /// Prompt shown in front of the text being typed.
 const INPUT_PROMPT: &str = "> ";
-/// Key help shown at the right of the status text.
-const KEY_HELP: &str =
-    "Tab:focus  Enter:apri/invia  F2:chat/contatti  Ctrl+A:AI chat  Ctrl+C:esci";
+/// Key help shown before the global autoreply shortcut.
+const KEY_HELP_BEFORE_GLOBAL: &str =
+    "Tab:focus  Enter:apri/invia  F2:chat/contatti  Canc:elimina  Ctrl+A:AI chat  ";
+/// Key help of the global autoreply shortcut, highlighted when it is on.
+const KEY_HELP_GLOBAL: &str = "Ctrl+Shift+A:AI tutte";
+/// Key help shown after the global autoreply shortcut.
+const KEY_HELP_AFTER_GLOBAL: &str = "  Ctrl+Q:esci";
 /// Gap between the status text and the key help.
 const FOOTER_GAP: &str = "   ";
 /// Leading space of the footer line.
@@ -230,13 +238,19 @@ fn pane_block(title: String, focused: bool) -> Block<'static> {
 /// Output: none.
 /// Side effects: writes to the frame buffer.
 fn render_list_pane(frame: &mut Frame, area: Rect, app: &App) {
-    let (title, items): (&str, Vec<ListItem>) = match app.list_mode {
-        ListMode::Chats => (TITLE_CHATS, app.chats.iter().map(chat_item).collect()),
-        ListMode::Contacts => (TITLE_CONTACTS, app.contacts.iter().map(contact_item).collect()),
+    let (title, items): (String, Vec<ListItem>) = match app.list_mode {
+        ListMode::Chats => (
+            list_title(TITLE_CHATS, app.global_autoreply, &app.filter),
+            app.visible_chats().into_iter().map(chat_item).collect(),
+        ),
+        ListMode::Contacts => (
+            list_title(TITLE_CONTACTS, false, &app.filter),
+            app.visible_contacts().into_iter().map(contact_item).collect(),
+        ),
     };
     let len = items.len();
     let list = List::new(items)
-        .block(pane_block(title.to_string(), app.focus == Focus::List))
+        .block(pane_block(title, app.focus == Focus::List))
         .highlight_style(Style::new().add_modifier(Modifier::REVERSED))
         .highlight_symbol(HIGHLIGHT_SYMBOL);
     let mut state = ListState::default().with_selected(selected_index(len, app.selected));
@@ -244,6 +258,22 @@ fn render_list_pane(frame: &mut Frame, area: Rect, app: &App) {
     frame.render_stateful_widget(list, area, &mut state);
 }
 
+/// Purpose: build the list pane title, appending the global autoreply marker and the search text.
+/// Inputs: the `base` title, whether the `global_autoreply` is enabled and the active `filter`.
+/// Output: `base`, followed by the global marker when enabled, followed by the filter label and
+/// text when `filter` is non-empty.
+/// Side effects: none.
+fn list_title(base: &str, global_autoreply: bool, filter: &str) -> String {
+    let mut title = base.to_string();
+    if global_autoreply {
+        title.push_str(AI_GLOBAL_MARKER);
+    }
+    if !filter.is_empty() {
+        title.push_str(&format!("{FILTER_LABEL}{filter} "));
+    }
+    title
+}
+
 /// Purpose: compute the row to highlight in a list.
 /// Inputs: the list length `len` and the requested `selected` row.
 /// Output: `selected` clamped to the last row, or `None` if the list is empty.
@@ -291,7 +321,8 @@ fn messages_title(open_chat: Option<&str>, name: &str, autoreply: bool) -> Strin
 /// Output: none.
 /// Side effects: writes to the frame buffer.
 fn render_messages_pane(frame: &mut Frame, area: Rect, app: &App) {
-    let title = messages_title(app.open_chat.as_deref(), &app.open_chat_name, app.open_chat_autoreply);
+    let autoreply = app.open_chat_autoreply || app.global_autoreply;
+    let title = messages_title(app.open_chat.as_deref(), &app.open_chat_name, autoreply);
     let block = pane_block(title, false);
     let inner = block.inner(area);
     let lines = visible_message_lines(&app.messages, inner.width, inner.height);
@@ -490,19 +521,38 @@ fn tail_fitting(text: &str, max_width: usize) -> &str {
     &text[start..]
 }
 
+/// Purpose: build the key help spans, highlighting the global autoreply shortcut when it is on.
+/// Inputs: whether the autoreply is on for every chat.
+/// Output: the spans before, of and after the global shortcut, in this order.
+/// Side effects: none.
+fn key_help_spans(global_autoreply: bool) -> Vec<Span<'static>> {
+    let dim = Style::new().add_modifier(Modifier::DIM);
+    let global_style = if global_autoreply {
+        Style::new().fg(AI_MARKER_COLOR)
+    } else {
+        dim
+    };
+
+    vec![
+        Span::styled(KEY_HELP_BEFORE_GLOBAL, dim),
+        Span::styled(KEY_HELP_GLOBAL, global_style),
+        Span::styled(KEY_HELP_AFTER_GLOBAL, dim),
+    ]
+}
+
 /// Purpose: draw the bottom line with the status and the key help.
 /// Inputs: frame, the footer area and the app.
 /// Output: none.
 /// Side effects: writes to the frame buffer.
 fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
-    let line = Line::from(vec![
+    let mut spans = vec![
         Span::raw(FOOTER_MARGIN),
         Span::styled(app.status.clone(), Style::new().add_modifier(Modifier::BOLD)),
         Span::raw(FOOTER_GAP),
-        Span::styled(KEY_HELP, Style::new().add_modifier(Modifier::DIM)),
-    ]);
+    ];
+    spans.extend(key_help_spans(app.global_autoreply));
 
-    frame.render_widget(Paragraph::new(line), area);
+    frame.render_widget(Paragraph::new(Line::from(spans)), area);
 }
 
 #[cfg(test)]
@@ -532,6 +582,27 @@ mod tests {
         }
     }
 
+    #[test]
+    fn key_help_spans_highlights_global_shortcut_only_when_on() {
+        let on = key_help_spans(true);
+        let off = key_help_spans(false);
+
+        let text: String = on.iter().map(|span| span.content.as_ref()).collect();
+        assert_eq!(
+            text,
+            format!("{KEY_HELP_BEFORE_GLOBAL}{KEY_HELP_GLOBAL}{KEY_HELP_AFTER_GLOBAL}")
+        );
+        assert!(text.contains("Ctrl+Q:esci"));
+        assert!(!text.contains("Ctrl+C"));
+
+        let global_on = on.iter().find(|span| span.content == KEY_HELP_GLOBAL).unwrap();
+        assert_eq!(global_on.style.fg, Some(AI_MARKER_COLOR));
+
+        let global_off = off.iter().find(|span| span.content == KEY_HELP_GLOBAL).unwrap();
+        assert_eq!(global_off.style.fg, None);
+        assert!(global_off.style.add_modifier.contains(Modifier::DIM));
+    }
+
     #[test]
     fn incoming_message_uses_sender_name() {
         let line = format_message_line_in(&incoming("ciao"), &Utc);
@@ -626,6 +697,27 @@ mod tests {
         assert!(on.contains("AI: ON"));
     }
 
+    #[test]
+    fn list_title_marks_global_autoreply() {
+        let on = list_title(TITLE_CHATS, true, "");
+        assert!(on.starts_with(TITLE_CHATS));
+        assert!(on.contains(AI_GLOBAL_MARKER));
+
+        assert_eq!(list_title(TITLE_CHATS, false, ""), TITLE_CHATS);
+    }
+
+    #[test]
+    fn list_title_shows_filter() {
+        let filtered = list_title(TITLE_CHATS, false, "ab");
+        assert!(filtered.starts_with(TITLE_CHATS));
+        assert!(filtered.contains("Cerca: ab"));
+        assert!(!filtered.contains(AI_GLOBAL_MARKER));
+
+        let both = list_title(TITLE_CHATS, true, "ab");
+        assert!(both.contains(AI_GLOBAL_MARKER));
+        assert!(both.contains("Cerca: ab"));
+    }
+
     #[test]
     fn selected_index_is_clamped() {
         assert_eq!(selected_index(0, 0), None);

+ 25 - 4
src/whatsapp.rs

@@ -437,10 +437,16 @@ impl Translator {
             )),
             Event::ConnectFailure(failure) => self.report(describe_connect_failure(failure)),
             Event::StreamError(e) => self.report(format!("stream error: {}", e.code)),
-            Event::AppStateSyncFailed(f) => self.report(format!(
-                "app state sync incomplete (fatal: {:?}, retryable: {:?})",
-                f.fatal, f.retryable
-            )),
+            Event::AppStateSyncFailed(f) => {
+                // Retryable/skipped collections are retried automatically by the library
+                // (forced reconnect), so only fatal failures are shown to the user.
+                if is_fatal_sync_failure(&f.fatal) {
+                    self.report(format!(
+                        "app state sync incomplete (fatal: {:?}, retryable: {:?})",
+                        f.fatal, f.retryable
+                    ));
+                }
+            }
             Event::UndecryptableMessage(u) => self.report(format!(
                 "could not decrypt a message from {}",
                 jid_to_string(&u.info.source.sender)
@@ -799,6 +805,15 @@ fn describe_connect_failure(failure: &ConnectFailure) -> String {
     }
 }
 
+/// Tells whether an app state sync failure must be reported to the user.
+///
+/// Inputs: the names of the collections that failed fatally.
+/// Output: `true` when at least one collection failed fatally.
+/// Side effects: none.
+fn is_fatal_sync_failure(fatal: &[String]) -> bool {
+    !fatal.is_empty()
+}
+
 /// Returns the sender display name for a message.
 ///
 /// Inputs: the push name and whether the message was sent by this account.
@@ -1026,6 +1041,12 @@ mod tests {
         }
     }
 
+    #[test]
+    fn is_fatal_sync_failure_only_for_non_empty_fatal() {
+        assert!(!is_fatal_sync_failure(&[]));
+        assert!(is_fatal_sync_failure(&["regular_high".to_owned()]));
+    }
+
     #[test]
     fn addressed_jids_extracts_mentions_from_extended_text() {
         let message = text_with_context(wa::ContextInfo {