返回 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 }
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
286 lines RUST