| 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::settings::Settings; |
| 11 | use crate::tui::app::{App, StatusToast, StatusToastKind, StatusToastLevel}; |
| 12 | use codewhale_localization::{Locale, MessageId, tr}; |
| 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 | BackgroundJobReceipt, |
| 21 | ClearedInputRestore, |
| 22 | McpValidation, |
| 23 | RepeatedCommandHotbar, |
| 24 | DurableStateWritten, |
| 25 | #[expect(dead_code)] |
| 26 | TodoWriteHint, |
| 27 | } |
| 28 | |
| 29 | impl BehavioralTip { |
| 30 | const fn key(self) -> &'static str { |
| 31 | match self { |
| 32 | Self::BackgroundJobReceipt => "background_job_receipt", |
| 33 | Self::ClearedInputRestore => "cleared_input_restore", |
| 34 | Self::McpValidation => "mcp_validation", |
| 35 | Self::RepeatedCommandHotbar => "repeated_command_hotbar", |
| 36 | Self::DurableStateWritten => "durable_state_written", |
| 37 | Self::TodoWriteHint => "todo_write_hint", |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | const fn message_id(self) -> MessageId { |
| 42 | match self { |
| 43 | Self::BackgroundJobReceipt => MessageId::BehavioralTipBackgroundReceipt, |
| 44 | Self::ClearedInputRestore => MessageId::BehavioralTipClearedInput, |
| 45 | Self::McpValidation => MessageId::BehavioralTipMcpValidation, |
| 46 | Self::RepeatedCommandHotbar => MessageId::BehavioralTipRepeatedCommand, |
| 47 | Self::DurableStateWritten => MessageId::BehavioralTipDurableStateWritten, |
| 48 | Self::TodoWriteHint => MessageId::BehavioralTipTodoWrite, |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | fn message(self, locale: Locale) -> String { |
| 53 | let template = tr(locale, self.message_id()); |
| 54 | match self { |
| 55 | Self::BackgroundJobReceipt => template.replace("{key}", "Enter"), |
| 56 | Self::ClearedInputRestore => template.replace("{chord}", "Ctrl+Z"), |
| 57 | Self::McpValidation => template.replace("{command}", "codewhale mcp validate"), |
| 58 | Self::RepeatedCommandHotbar => template.replace("{command}", "/hotbar"), |
| 59 | Self::DurableStateWritten => template.replace("{command}", "/memory"), |
| 60 | Self::TodoWriteHint => template.replace("{command}", "todo_write"), |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | #[derive(Debug)] |
| 66 | pub struct BehavioralTipState { |
| 67 | enabled: bool, |
| 68 | shown_this_session: HashSet<BehavioralTip>, |
| 69 | session_impressions: u8, |
| 70 | manual_command_counts: HashMap<u64, u8>, |
| 71 | } |
| 72 | |
| 73 | impl Default for BehavioralTipState { |
| 74 | fn default() -> Self { |
| 75 | Self::new(true) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | impl BehavioralTipState { |
| 80 | pub fn new(enabled: bool) -> Self { |
| 81 | Self { |
| 82 | enabled, |
| 83 | shown_this_session: HashSet::new(), |
| 84 | session_impressions: 0, |
| 85 | manual_command_counts: HashMap::new(), |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | pub fn enabled(&self) -> bool { |
| 90 | self.enabled |
| 91 | } |
| 92 | |
| 93 | pub(crate) fn guidance_available(&self) -> bool { |
| 94 | self.enabled && self.session_impressions < MAX_TIPS_PER_SESSION |
| 95 | } |
| 96 | |
| 97 | fn eligible_in_session(&self, tip: BehavioralTip) -> bool { |
| 98 | self.guidance_available() && !self.shown_this_session.contains(&tip) |
| 99 | } |
| 100 | |
| 101 | fn eligible(&self, tip: BehavioralTip, lifetime_impressions: u8) -> bool { |
| 102 | self.eligible_in_session(tip) && lifetime_impressions < MAX_LIFETIME_IMPRESSIONS |
| 103 | } |
| 104 | |
| 105 | fn record_impression(&mut self, tip: BehavioralTip) { |
| 106 | self.shown_this_session.insert(tip); |
| 107 | self.record_guidance_impression(); |
| 108 | } |
| 109 | |
| 110 | pub(crate) fn record_guidance_impression(&mut self) { |
| 111 | self.session_impressions = self.session_impressions.saturating_add(1); |
| 112 | } |
| 113 | |
| 114 | fn note_manual_command(&mut self, input: &str) -> bool { |
| 115 | let Some(fingerprint) = manual_command_fingerprint(input) else { |
| 116 | return false; |
| 117 | }; |
| 118 | if self.manual_command_counts.len() >= MAX_TRACKED_MANUAL_COMMANDS |
| 119 | && !self.manual_command_counts.contains_key(&fingerprint) |
| 120 | { |
| 121 | return false; |
| 122 | } |
| 123 | let count = self.manual_command_counts.entry(fingerprint).or_default(); |
| 124 | *count = count.saturating_add(1); |
| 125 | *count == 3 |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | impl App { |
| 130 | /// A preference change never acknowledges errors, approvals, or recovery |
| 131 | /// notices. Keep impression caps intact when tips are enabled again. |
| 132 | pub fn set_contextual_tips_enabled(&mut self, enabled: bool) { |
| 133 | self.behavioral_tips.enabled = enabled; |
| 134 | if !enabled { |
| 135 | self.status_toasts.retain(|toast| { |
| 136 | !matches!( |
| 137 | toast.kind, |
| 138 | StatusToastKind::BehavioralTip(_) | StatusToastKind::PluginSuggestion |
| 139 | ) |
| 140 | }); |
| 141 | } |
| 142 | self.needs_redraw = true; |
| 143 | } |
| 144 | |
| 145 | /// Show a behavioral tip when both the quiet session cap and the persisted |
| 146 | /// lifetime cap allow it. Persistence is best-effort: a read-only home |
| 147 | /// must not make a useful in-session hint fail closed. |
| 148 | pub fn maybe_show_behavioral_tip(&mut self, tip: BehavioralTip) -> bool { |
| 149 | // Clear-input hooks are hot paths. Once the in-memory session gate is |
| 150 | // closed, avoid touching the settings file for every later keypress. |
| 151 | if !self.behavioral_tips.eligible_in_session(tip) { |
| 152 | return false; |
| 153 | } |
| 154 | // Tests never touch the settings file here, so the eligibility read uses |
| 155 | // in-memory defaults. Outside tests the read and the increment are one |
| 156 | // transaction: a read-modify-write on an impression counter is exactly |
| 157 | // what another whole-file writer would otherwise revert. |
| 158 | if cfg!(test) { |
| 159 | // No settings file is read or written, so the lifetime count is |
| 160 | // whatever `Settings::default()` carries: nothing. |
| 161 | if !self.behavioral_tips.eligible(tip, 0) { |
| 162 | return false; |
| 163 | } |
| 164 | self.behavioral_tips.record_impression(tip); |
| 165 | } else { |
| 166 | let eligible = Settings::transact_opt(|settings| { |
| 167 | let lifetime_impressions = settings |
| 168 | .behavioral_tip_impressions |
| 169 | .get(tip.key()) |
| 170 | .copied() |
| 171 | .unwrap_or(0); |
| 172 | if !self.behavioral_tips.eligible(tip, lifetime_impressions) { |
| 173 | return Ok(None); |
| 174 | } |
| 175 | settings.behavioral_tip_impressions.insert( |
| 176 | tip.key().to_string(), |
| 177 | lifetime_impressions.saturating_add(1), |
| 178 | ); |
| 179 | Ok(Some(())) |
| 180 | }); |
| 181 | match eligible { |
| 182 | Ok(None) => return false, |
| 183 | Ok(Some(())) => {} |
| 184 | Err(err) => { |
| 185 | tracing::warn!(tip = tip.key(), error = %err, "behavioral tip impression was not persisted"); |
| 186 | } |
| 187 | } |
| 188 | self.behavioral_tips.record_impression(tip); |
| 189 | } |
| 190 | let mut toast = StatusToast::new( |
| 191 | tip.message(self.ui_locale), |
| 192 | StatusToastLevel::Info, |
| 193 | Some(8_000), |
| 194 | ); |
| 195 | toast.kind = StatusToastKind::BehavioralTip(tip); |
| 196 | self.push_status_toast_record(toast); |
| 197 | true |
| 198 | } |
| 199 | |
| 200 | pub fn note_manual_command_for_tip(&mut self, input: &str) -> bool { |
| 201 | self.behavioral_tips.enabled |
| 202 | && self.behavioral_tips.note_manual_command(input) |
| 203 | && self.maybe_show_behavioral_tip(BehavioralTip::RepeatedCommandHotbar) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | fn manual_command_fingerprint(input: &str) -> Option<u64> { |
| 208 | let parts = input.split_whitespace().collect::<Vec<_>>(); |
| 209 | let command = parts.first()?; |
| 210 | if !command.starts_with('/') || command.eq_ignore_ascii_case("/hotbar") { |
| 211 | return None; |
| 212 | } |
| 213 | let normalized = parts.join(" "); |
| 214 | let mut hasher = DefaultHasher::new(); |
| 215 | normalized.hash(&mut hasher); |
| 216 | Some(hasher.finish()) |
| 217 | } |
| 218 | |
| 219 | #[cfg(test)] |
| 220 | mod tests { |
| 221 | use super::*; |
| 222 | |
| 223 | #[test] |
| 224 | fn session_and_lifetime_caps_keep_tips_quiet() { |
| 225 | let mut state = BehavioralTipState::default(); |
| 226 | assert!(state.eligible(BehavioralTip::McpValidation, 0)); |
| 227 | state.record_impression(BehavioralTip::McpValidation); |
| 228 | assert!(!state.eligible(BehavioralTip::McpValidation, 0)); |
| 229 | assert!(!state.eligible(BehavioralTip::BackgroundJobReceipt, 0)); |
| 230 | |
| 231 | let fresh_session = BehavioralTipState::default(); |
| 232 | assert!(fresh_session.eligible(BehavioralTip::McpValidation, 1)); |
| 233 | assert!(!fresh_session.eligible(BehavioralTip::McpValidation, MAX_LIFETIME_IMPRESSIONS)); |
| 234 | } |
| 235 | |
| 236 | #[test] |
| 237 | fn third_matching_manual_command_triggers_once() { |
| 238 | let mut state = BehavioralTipState::default(); |
| 239 | assert!(!state.note_manual_command("/model one")); |
| 240 | assert!(!state.note_manual_command("/model two")); |
| 241 | assert!(!state.note_manual_command(" /model one ")); |
| 242 | assert!(state.note_manual_command("/model one")); |
| 243 | assert!(!state.note_manual_command("/model one")); |
| 244 | assert!(!state.note_manual_command("/hotbar")); |
| 245 | assert!(!state.note_manual_command("ordinary prompt")); |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn every_complete_locale_renders_tips_with_code_owned_controls() { |
| 250 | let tips = [ |
| 251 | BehavioralTip::BackgroundJobReceipt, |
| 252 | BehavioralTip::ClearedInputRestore, |
| 253 | BehavioralTip::McpValidation, |
| 254 | BehavioralTip::RepeatedCommandHotbar, |
| 255 | BehavioralTip::DurableStateWritten, |
| 256 | ]; |
| 257 | for locale in Locale::shipped_complete() { |
| 258 | for tip in tips { |
| 259 | let message = tip.message(*locale); |
| 260 | assert!(!message.contains('{'), "unexpanded placeholder: {message}"); |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | assert_eq!( |
| 265 | BehavioralTip::BackgroundJobReceipt.message(Locale::En), |
| 266 | "Receipts live in the Work panel — Enter opens the inspector" |
| 267 | ); |
| 268 | assert_eq!( |
| 269 | BehavioralTip::ClearedInputRestore.message(Locale::En), |
| 270 | "Cleared · Ctrl+Z restores" |
| 271 | ); |
| 272 | assert_eq!( |
| 273 | BehavioralTip::McpValidation.message(Locale::En), |
| 274 | "codewhale mcp validate starts servers and shows why" |
| 275 | ); |
| 276 | assert_eq!( |
| 277 | BehavioralTip::RepeatedCommandHotbar.message(Locale::En), |
| 278 | "/hotbar can pin this" |
| 279 | ); |
| 280 | assert_eq!( |
| 281 | BehavioralTip::DurableStateWritten.message(Locale::En), |
| 282 | "Saved · /memory to inspect" |
| 283 | ); |
| 284 | } |
| 285 | } |
| 286 |