返回 CodeWhale
behavioral_tips.rs
根目录 / crates / tui / src / tui / behavioral_tips.rs
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
270 lines RUST