返回 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::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 // One switch governs every plugin offer, the review row included.
142 self.plugin_cta.phase = crate::tui::plugin_suggestions::PluginCtaPhase::Hidden;
143 }
144 self.needs_redraw = true;
145 }
146
147 /// Show a behavioral tip when both the quiet session cap and the persisted
148 /// lifetime cap allow it. Persistence is best-effort: a read-only home
149 /// must not make a useful in-session hint fail closed.
150 pub fn maybe_show_behavioral_tip(&mut self, tip: BehavioralTip) -> bool {
151 // Clear-input hooks are hot paths. Once the in-memory session gate is
152 // closed, avoid touching the settings file for every later keypress.
153 if !self.behavioral_tips.eligible_in_session(tip) {
154 return false;
155 }
156 // Tests never touch the settings file here, so the eligibility read uses
157 // in-memory defaults. Outside tests the read and the increment are one
158 // transaction: a read-modify-write on an impression counter is exactly
159 // what another whole-file writer would otherwise revert.
160 if cfg!(test) {
161 // No settings file is read or written, so the lifetime count is
162 // whatever `Settings::default()` carries: nothing.
163 if !self.behavioral_tips.eligible(tip, 0) {
164 return false;
165 }
166 self.behavioral_tips.record_impression(tip);
167 } else {
168 let eligible = Settings::transact_opt(|settings| {
169 let lifetime_impressions = settings
170 .behavioral_tip_impressions
171 .get(tip.key())
172 .copied()
173 .unwrap_or(0);
174 if !self.behavioral_tips.eligible(tip, lifetime_impressions) {
175 return Ok(None);
176 }
177 settings.behavioral_tip_impressions.insert(
178 tip.key().to_string(),
179 lifetime_impressions.saturating_add(1),
180 );
181 Ok(Some(()))
182 });
183 match eligible {
184 Ok(None) => return false,
185 Ok(Some(())) => {}
186 Err(err) => {
187 tracing::warn!(tip = tip.key(), error = %err, "behavioral tip impression was not persisted");
188 }
189 }
190 self.behavioral_tips.record_impression(tip);
191 }
192 let mut toast = StatusToast::new(
193 tip.message(self.ui_locale),
194 StatusToastLevel::Info,
195 Some(8_000),
196 );
197 toast.kind = StatusToastKind::BehavioralTip(tip);
198 self.push_status_toast_record(toast);
199 true
200 }
201
202 pub fn note_manual_command_for_tip(&mut self, input: &str) -> bool {
203 self.behavioral_tips.enabled
204 && self.behavioral_tips.note_manual_command(input)
205 && self.maybe_show_behavioral_tip(BehavioralTip::RepeatedCommandHotbar)
206 }
207 }
208
209 fn manual_command_fingerprint(input: &str) -> Option<u64> {
210 let parts = input.split_whitespace().collect::<Vec<_>>();
211 let command = parts.first()?;
212 if !command.starts_with('/') || command.eq_ignore_ascii_case("/hotbar") {
213 return None;
214 }
215 let normalized = parts.join(" ");
216 let mut hasher = DefaultHasher::new();
217 normalized.hash(&mut hasher);
218 Some(hasher.finish())
219 }
220
221 #[cfg(test)]
222 mod tests {
223 use super::*;
224
225 #[test]
226 fn session_and_lifetime_caps_keep_tips_quiet() {
227 let mut state = BehavioralTipState::default();
228 assert!(state.eligible(BehavioralTip::McpValidation, 0));
229 state.record_impression(BehavioralTip::McpValidation);
230 assert!(!state.eligible(BehavioralTip::McpValidation, 0));
231 assert!(!state.eligible(BehavioralTip::BackgroundJobReceipt, 0));
232
233 let fresh_session = BehavioralTipState::default();
234 assert!(fresh_session.eligible(BehavioralTip::McpValidation, 1));
235 assert!(!fresh_session.eligible(BehavioralTip::McpValidation, MAX_LIFETIME_IMPRESSIONS));
236 }
237
238 #[test]
239 fn third_matching_manual_command_triggers_once() {
240 let mut state = BehavioralTipState::default();
241 assert!(!state.note_manual_command("/model one"));
242 assert!(!state.note_manual_command("/model two"));
243 assert!(!state.note_manual_command(" /model one "));
244 assert!(state.note_manual_command("/model one"));
245 assert!(!state.note_manual_command("/model one"));
246 assert!(!state.note_manual_command("/hotbar"));
247 assert!(!state.note_manual_command("ordinary prompt"));
248 }
249
250 #[test]
251 fn every_complete_locale_renders_tips_with_code_owned_controls() {
252 let tips = [
253 BehavioralTip::BackgroundJobReceipt,
254 BehavioralTip::ClearedInputRestore,
255 BehavioralTip::McpValidation,
256 BehavioralTip::RepeatedCommandHotbar,
257 BehavioralTip::DurableStateWritten,
258 ];
259 for locale in Locale::shipped_complete() {
260 for tip in tips {
261 let message = tip.message(*locale);
262 assert!(!message.contains('{'), "unexpanded placeholder: {message}");
263 }
264 }
265
266 assert_eq!(
267 BehavioralTip::BackgroundJobReceipt.message(Locale::En),
268 "Receipts live in the Work panel — Enter opens the inspector"
269 );
270 assert_eq!(
271 BehavioralTip::ClearedInputRestore.message(Locale::En),
272 "Cleared · Ctrl+Z restores"
273 );
274 assert_eq!(
275 BehavioralTip::McpValidation.message(Locale::En),
276 "codewhale mcp validate starts servers and shows why"
277 );
278 assert_eq!(
279 BehavioralTip::RepeatedCommandHotbar.message(Locale::En),
280 "/hotbar can pin this"
281 );
282 assert_eq!(
283 BehavioralTip::DurableStateWritten.message(Locale::En),
284 "Saved · /memory to inspect"
285 );
286 }
287 }
288
288 lines RUST