| 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 |