| 1 | //! Quiet, action-triggered product guidance. |
| 2 | //! |
| 3 | //! These tips are deliberately event-driven rather than timer-driven. The |
| 4 | //! session gate keeps the TUI calm, while persisted impression counts prevent |
| 5 | //! a useful first-run hint from becoming permanent chrome. |
| 6 | |
| 7 | use std::collections::{HashMap, HashSet}; |
| 8 | use std::hash::{DefaultHasher, Hash, Hasher}; |
| 9 | |
| 10 | use crate::localization::{Locale, MessageId, tr}; |
| 11 | use crate::settings::Settings; |
| 12 | use crate::tui::app::{App, AppMode, StatusToastLevel}; |
| 13 | |
| 14 | const MAX_TIPS_PER_SESSION: u8 = 1; |
| 15 | const MAX_LIFETIME_IMPRESSIONS: u8 = 2; |
| 16 | const MAX_TRACKED_MANUAL_COMMANDS: usize = 128; |
| 17 | |
| 18 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| 19 | pub enum BehavioralTip { |
| 20 | PlanningMode, |
| 21 | BackgroundJobReceipt, |
| 22 | ClearedInputRestore, |
| 23 | McpValidation, |
| 24 | RepeatedCommandHotbar, |
| 25 | } |
| 26 | |
| 27 | impl BehavioralTip { |
| 28 | const fn key(self) -> &'static str { |
| 29 | match self { |
| 30 | Self::PlanningMode => "planning_mode", |
| 31 | Self::BackgroundJobReceipt => "background_job_receipt", |
| 32 | Self::ClearedInputRestore => "cleared_input_restore", |
| 33 | Self::McpValidation => "mcp_validation", |
| 34 | Self::RepeatedCommandHotbar => "repeated_command_hotbar", |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | const fn message_id(self) -> MessageId { |
| 39 | match self { |
| 40 | Self::PlanningMode => MessageId::BehavioralTipPlanning, |
| 41 | Self::BackgroundJobReceipt => MessageId::BehavioralTipBackgroundReceipt, |
| 42 | Self::ClearedInputRestore => MessageId::BehavioralTipClearedInput, |
| 43 | Self::McpValidation => MessageId::BehavioralTipMcpValidation, |
| 44 | Self::RepeatedCommandHotbar => MessageId::BehavioralTipRepeatedCommand, |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | fn message(self, locale: Locale) -> String { |
| 49 | let template = tr(locale, self.message_id()); |
| 50 | match self { |
| 51 | Self::PlanningMode => template.replace("{key}", "Tab"), |
| 52 | Self::BackgroundJobReceipt => template.replace("{key}", "Enter"), |
| 53 | Self::ClearedInputRestore => template.replace("{chord}", "Ctrl+Z"), |
| 54 | Self::McpValidation => template.replace("{command}", "codewhale mcp validate"), |
| 55 | Self::RepeatedCommandHotbar => template.replace("{command}", "/hotbar"), |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | #[derive(Debug, Default)] |
| 61 | pub struct BehavioralTipState { |
| 62 | shown_this_session: HashSet<BehavioralTip>, |
| 63 | session_impressions: u8, |
| 64 | manual_command_counts: HashMap<u64, u8>, |
| 65 | } |
| 66 | |
| 67 | impl BehavioralTipState { |
| 68 | fn eligible_in_session(&self, tip: BehavioralTip) -> bool { |
| 69 | self.session_impressions < MAX_TIPS_PER_SESSION && !self.shown_this_session.contains(&tip) |
| 70 | } |
| 71 | |
| 72 | fn eligible(&self, tip: BehavioralTip, lifetime_impressions: u8) -> bool { |
| 73 | self.eligible_in_session(tip) && lifetime_impressions < MAX_LIFETIME_IMPRESSIONS |
| 74 | } |
| 75 | |
| 76 | fn record_impression(&mut self, tip: BehavioralTip) { |
| 77 | self.shown_this_session.insert(tip); |
| 78 | self.session_impressions = self.session_impressions.saturating_add(1); |
| 79 | } |
| 80 | |
| 81 | fn note_manual_command(&mut self, input: &str) -> bool { |
| 82 | let Some(fingerprint) = manual_command_fingerprint(input) else { |
| 83 | return false; |
| 84 | }; |
| 85 | if self.manual_command_counts.len() >= MAX_TRACKED_MANUAL_COMMANDS |
| 86 | && !self.manual_command_counts.contains_key(&fingerprint) |
| 87 | { |
| 88 | return false; |
| 89 | } |
| 90 | let count = self.manual_command_counts.entry(fingerprint).or_default(); |
| 91 | *count = count.saturating_add(1); |
| 92 | *count == 3 |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | impl App { |
| 97 | /// Show a behavioral tip when both the quiet session cap and the persisted |
| 98 | /// lifetime cap allow it. Persistence is best-effort: a read-only home |
| 99 | /// must not make a useful in-session hint fail closed. |
| 100 | pub fn maybe_show_behavioral_tip(&mut self, tip: BehavioralTip) -> bool { |
| 101 | // Clear-input hooks are hot paths. Once the in-memory session gate is |
| 102 | // closed, avoid touching the settings file for every later keypress. |
| 103 | if !self.behavioral_tips.eligible_in_session(tip) { |
| 104 | return false; |
| 105 | } |
| 106 | // Tests never touch the settings file here, so the eligibility read uses |
| 107 | // in-memory defaults. Outside tests the read and the increment are one |
| 108 | // transaction: a read-modify-write on an impression counter is exactly |
| 109 | // what another whole-file writer would otherwise revert. |
| 110 | if cfg!(test) { |
| 111 | // No settings file is read or written, so the lifetime count is |
| 112 | // whatever `Settings::default()` carries: nothing. |
| 113 | if !self.behavioral_tips.eligible(tip, 0) { |
| 114 | return false; |
| 115 | } |
| 116 | self.behavioral_tips.record_impression(tip); |
| 117 | } else { |
| 118 | let eligible = Settings::transact_opt(|settings| { |
| 119 | let lifetime_impressions = settings |
| 120 | .behavioral_tip_impressions |
| 121 | .get(tip.key()) |
| 122 | .copied() |
| 123 | .unwrap_or(0); |
| 124 | if !self.behavioral_tips.eligible(tip, lifetime_impressions) { |
| 125 | return Ok(None); |
| 126 | } |
| 127 | settings.behavioral_tip_impressions.insert( |
| 128 | tip.key().to_string(), |
| 129 | lifetime_impressions.saturating_add(1), |
| 130 | ); |
| 131 | Ok(Some(())) |
| 132 | }); |
| 133 | match eligible { |
| 134 | Ok(None) => return false, |
| 135 | Ok(Some(())) => {} |
| 136 | Err(err) => { |
| 137 | tracing::warn!(tip = tip.key(), error = %err, "behavioral tip impression was not persisted"); |
| 138 | } |
| 139 | } |
| 140 | self.behavioral_tips.record_impression(tip); |
| 141 | } |
| 142 | self.push_status_toast( |
| 143 | tip.message(self.ui_locale), |
| 144 | StatusToastLevel::Info, |
| 145 | Some(8_000), |
| 146 | ); |
| 147 | true |
| 148 | } |
| 149 | |
| 150 | pub fn maybe_nudge_for_planning_prompt(&mut self, input: &str) -> bool { |
| 151 | self.mode != AppMode::Plan |
| 152 | && looks_like_planning_prompt(input) |
| 153 | && self.maybe_show_behavioral_tip(BehavioralTip::PlanningMode) |
| 154 | } |
| 155 | |
| 156 | pub fn note_manual_command_for_tip(&mut self, input: &str) -> bool { |
| 157 | self.behavioral_tips.note_manual_command(input) |
| 158 | && self.maybe_show_behavioral_tip(BehavioralTip::RepeatedCommandHotbar) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | fn manual_command_fingerprint(input: &str) -> Option<u64> { |
| 163 | let parts = input.split_whitespace().collect::<Vec<_>>(); |
| 164 | let command = parts.first()?; |
| 165 | if !command.starts_with('/') || command.eq_ignore_ascii_case("/hotbar") { |
| 166 | return None; |
| 167 | } |
| 168 | let normalized = parts.join(" "); |
| 169 | let mut hasher = DefaultHasher::new(); |
| 170 | normalized.hash(&mut hasher); |
| 171 | Some(hasher.finish()) |
| 172 | } |
| 173 | |
| 174 | fn looks_like_planning_prompt(input: &str) -> bool { |
| 175 | let normalized = input |
| 176 | .to_ascii_lowercase() |
| 177 | .chars() |
| 178 | .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' }) |
| 179 | .collect::<String>(); |
| 180 | let words = normalized.split_whitespace().collect::<Vec<_>>(); |
| 181 | let has_word = |needle: &str| words.contains(&needle); |
| 182 | |
| 183 | ["plan", "planning", "roadmap", "strategy", "outline"] |
| 184 | .into_iter() |
| 185 | .any(has_word) |
| 186 | || normalized.contains("how should we") |
| 187 | || normalized.contains("before we start") |
| 188 | || normalized.contains("step by step") |
| 189 | } |
| 190 | |
| 191 | #[cfg(test)] |
| 192 | mod tests { |
| 193 | use super::*; |
| 194 | |
| 195 | #[test] |
| 196 | fn planning_detector_matches_intent_without_substring_false_positives() { |
| 197 | assert!(looks_like_planning_prompt( |
| 198 | "Please outline a migration strategy" |
| 199 | )); |
| 200 | assert!(looks_like_planning_prompt("How should we approach this?")); |
| 201 | assert!(!looks_like_planning_prompt( |
| 202 | "Explain the planetary boundary" |
| 203 | )); |
| 204 | assert!(!looks_like_planning_prompt("Fix the failing test")); |
| 205 | } |
| 206 | |
| 207 | #[test] |
| 208 | fn session_and_lifetime_caps_keep_tips_quiet() { |
| 209 | let mut state = BehavioralTipState::default(); |
| 210 | assert!(state.eligible(BehavioralTip::PlanningMode, 0)); |
| 211 | state.record_impression(BehavioralTip::PlanningMode); |
| 212 | assert!(!state.eligible(BehavioralTip::PlanningMode, 0)); |
| 213 | assert!(!state.eligible(BehavioralTip::McpValidation, 0)); |
| 214 | |
| 215 | let fresh_session = BehavioralTipState::default(); |
| 216 | assert!(fresh_session.eligible(BehavioralTip::PlanningMode, 1)); |
| 217 | assert!(!fresh_session.eligible(BehavioralTip::PlanningMode, MAX_LIFETIME_IMPRESSIONS)); |
| 218 | } |
| 219 | |
| 220 | #[test] |
| 221 | fn third_matching_manual_command_triggers_once() { |
| 222 | let mut state = BehavioralTipState::default(); |
| 223 | assert!(!state.note_manual_command("/model one")); |
| 224 | assert!(!state.note_manual_command("/model two")); |
| 225 | assert!(!state.note_manual_command(" /model one ")); |
| 226 | assert!(state.note_manual_command("/model one")); |
| 227 | assert!(!state.note_manual_command("/model one")); |
| 228 | assert!(!state.note_manual_command("/hotbar")); |
| 229 | assert!(!state.note_manual_command("ordinary prompt")); |
| 230 | } |
| 231 | |
| 232 | #[test] |
| 233 | fn every_complete_locale_renders_tips_with_code_owned_controls() { |
| 234 | let tips = [ |
| 235 | BehavioralTip::PlanningMode, |
| 236 | BehavioralTip::BackgroundJobReceipt, |
| 237 | BehavioralTip::ClearedInputRestore, |
| 238 | BehavioralTip::McpValidation, |
| 239 | BehavioralTip::RepeatedCommandHotbar, |
| 240 | ]; |
| 241 | for locale in Locale::shipped_complete() { |
| 242 | for tip in tips { |
| 243 | let message = tip.message(*locale); |
| 244 | assert!(!message.contains('{'), "unexpanded placeholder: {message}"); |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | assert_eq!( |
| 249 | BehavioralTip::PlanningMode.message(Locale::En), |
| 250 | "Planning? Tab cycles to Plan mode" |
| 251 | ); |
| 252 | assert_eq!( |
| 253 | BehavioralTip::BackgroundJobReceipt.message(Locale::En), |
| 254 | "Receipts live in the Work panel — Enter opens the inspector" |
| 255 | ); |
| 256 | assert_eq!( |
| 257 | BehavioralTip::ClearedInputRestore.message(Locale::En), |
| 258 | "Cleared · Ctrl+Z restores" |
| 259 | ); |
| 260 | assert_eq!( |
| 261 | BehavioralTip::McpValidation.message(Locale::En), |
| 262 | "codewhale mcp validate starts servers and shows why" |
| 263 | ); |
| 264 | assert_eq!( |
| 265 | BehavioralTip::RepeatedCommandHotbar.message(Locale::En), |
| 266 | "/hotbar can pin this" |
| 267 | ); |
| 268 | } |
| 269 | } |
| 270 |