返回 CodeWhale
footer_hints.rs
根目录 / crates / tui / src / tui / footer_hints.rs
1 //! Footer key hints stand down once their binding has been used.
2 //!
3 //! The posture bar teaches each chord only until the user has pressed it
4 //! [`USES_TO_RETIRE`] times; after that the chip goes bare (or the hint
5 //! goes away) and the row stays quiet. Counts persist in [`Settings`]
6 //! beside `behavioral_tip_impressions`, so a learned binding stays learned
7 //! across sessions.
8 //!
9 //! [`Settings`]: crate::settings::Settings
10
11 use std::collections::BTreeMap;
12
13 use crate::settings::Settings;
14 use crate::tui::app::App;
15
16 /// Uses of a binding after which its footer hint retires.
17 pub(crate) const USES_TO_RETIRE: u8 = 2;
18
19 /// Stable hint keys. The footer shows a hint until its key reaches
20 /// [`USES_TO_RETIRE`] uses, then renders the bare state.
21 pub(crate) const PERMISSION_CYCLE: &str = "permission_cycle";
22 pub(crate) const MODE_CYCLE: &str = "mode_cycle";
23 pub(crate) const ESC_INTERRUPT: &str = "esc_interrupt";
24 pub(crate) const ENTER_AGAIN: &str = "enter_again";
25 pub(crate) const AGENT_ARROWS: &str = "agent_arrows";
26 /// The idle bottom-of-screen affordance that opens the work dock. Founder
27 /// live-test: "what do we press at the bottom to get the workbar to show up?"
28 pub(crate) const DOCK_OPEN: &str = "dock_open";
29 /// The `/help` route hint on the metrics line. It retires like every other
30 /// hint so the row stops advertising a route the user already knows
31 /// (founder, 2026-09-08: an always-on key hint is noise).
32 pub(crate) const HELP_ROUTE: &str = "help_route";
33
34 /// Whether the hint for `key` has been used often enough to retire.
35 pub(crate) fn retired(uses: &BTreeMap<String, u8>, key: &str) -> bool {
36 uses.get(key).copied().unwrap_or(0) >= USES_TO_RETIRE
37 }
38
39 impl App {
40 /// Record one use of the binding behind footer hint `key`.
41 ///
42 /// Persistence is best-effort and never blocks the input path: once a
43 /// hint is retired on disk the transaction is abandoned without a write,
44 /// so each key costs at most [`USES_TO_RETIRE`] writes per install. A
45 /// read-only home still retires the hint for the running session.
46 pub(crate) fn note_footer_hint_used(&mut self, key: &str) {
47 if cfg!(test) {
48 // Tests never touch the settings file here, so only the
49 // in-memory count moves. Outside tests the read and the bump are
50 // one transaction, exactly like the behavioral-tip impressions.
51 let count = self.footer_hint_uses.entry(key.to_string()).or_default();
52 *count = count.saturating_add(1);
53 return;
54 }
55 let owned = key.to_string();
56 let persisted = Settings::transact_opt(|settings| {
57 let count = settings.footer_hint_uses.get(&owned).copied().unwrap_or(0);
58 if count >= USES_TO_RETIRE {
59 return Ok(None);
60 }
61 let next = count.saturating_add(1);
62 settings.footer_hint_uses.insert(owned.clone(), next);
63 Ok(Some(next))
64 });
65 match persisted {
66 Ok(next) => {
67 // `None` means already retired on disk: pin the in-memory
68 // count at the retire line so the next frame stands down.
69 let count = self.footer_hint_uses.entry(owned).or_default();
70 *count = (*count).max(next.unwrap_or(USES_TO_RETIRE));
71 }
72 Err(err) => {
73 tracing::warn!(hint = key, error = %err, "footer hint use was not persisted");
74 let count = self.footer_hint_uses.entry(owned).or_default();
75 *count = count.saturating_add(1);
76 }
77 }
78 }
79 }
80
81 #[cfg(test)]
82 mod tests {
83 use super::*;
84
85 #[test]
86 fn hints_retire_after_two_uses() {
87 let uses = BTreeMap::new();
88 assert!(!retired(&uses, PERMISSION_CYCLE));
89 for key in [
90 PERMISSION_CYCLE,
91 MODE_CYCLE,
92 ESC_INTERRUPT,
93 ENTER_AGAIN,
94 AGENT_ARROWS,
95 ] {
96 let mut uses = BTreeMap::new();
97 uses.insert(key.to_string(), 1);
98 assert!(!retired(&uses, key), "{key} at 1 use still shows");
99 uses.insert(key.to_string(), USES_TO_RETIRE);
100 assert!(retired(&uses, key), "{key} at 2 uses is gone");
101 uses.insert(key.to_string(), u8::MAX);
102 assert!(retired(&uses, key), "{key} stays retired");
103 }
104 }
105
106 #[test]
107 fn recorded_uses_accumulate_per_key() {
108 let mut app = crate::test_support::test_app_with_options(
109 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
110 );
111 app.note_footer_hint_used(PERMISSION_CYCLE);
112 assert_eq!(app.footer_hint_uses.get(PERMISSION_CYCLE).copied(), Some(1));
113 assert!(!retired(&app.footer_hint_uses, PERMISSION_CYCLE));
114 app.note_footer_hint_used(PERMISSION_CYCLE);
115 assert!(retired(&app.footer_hint_uses, PERMISSION_CYCLE));
116 // Other keys are unaffected.
117 assert!(!retired(&app.footer_hint_uses, MODE_CYCLE));
118 }
119 }
120
120 lines RUST