| 1 | //! The TUI's user-facing operating mode. Lives in codewhale-config so |
| 2 | //! settings, receipts, and other crates can name it without depending on |
| 3 | //! the TUI; the TUI adds the localized picker strings through an extension |
| 4 | //! trait. |
| 5 | |
| 6 | /// Supported application modes for the TUI. |
| 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 8 | pub enum AppMode { |
| 9 | Agent, |
| 10 | Plan, |
| 11 | Operate, |
| 12 | } |
| 13 | |
| 14 | impl AppMode { |
| 15 | /// Productive keyboard cycle: Plan -> Act -> Operate -> Plan. |
| 16 | /// |
| 17 | /// Operate joins the visible cycle as the always-on fleet operation: |
| 18 | /// a lead plans slices, then workers execute against an optional burn rate. |
| 19 | pub const CYCLE: [Self; 3] = [Self::Plan, Self::Agent, Self::Operate]; |
| 20 | |
| 21 | #[must_use] |
| 22 | pub fn parse(value: &str) -> Option<Self> { |
| 23 | match value.trim().to_ascii_lowercase().as_str() { |
| 24 | "agent" | "act" | "work" | "auto" | "1" => Some(Self::Agent), |
| 25 | "plan" | "2" => Some(Self::Plan), |
| 26 | "operate" | "operation" | "ops" | "3" => Some(Self::Operate), |
| 27 | // Invisible one-way permission shorthand only — never a visible |
| 28 | // mode. These spellings resolve to Act; the bypass posture they |
| 29 | // imply is carried by the permission surface (settings load, |
| 30 | // CLI/runtime wire), not by a mode. |
| 31 | other if Self::is_legacy_bypass_alias(other) => Some(Self::Agent), |
| 32 | _ => None, |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | /// Legacy mode spellings (`yolo`, `4`, `bypass`, `bypass-permissions`, |
| 37 | /// `bypasspermissions`) that carried the Full Access posture. [`Self::parse`] |
| 38 | /// folds them to Act; callers that must preserve the permission meaning |
| 39 | /// (the `/mode` command, the runtime policy wire, persisted thread records) |
| 40 | /// ask this one predicate instead of keeping their own copy of the list. |
| 41 | #[must_use] |
| 42 | pub fn is_legacy_bypass_alias(value: &str) -> bool { |
| 43 | matches!( |
| 44 | value.trim().to_ascii_lowercase().as_str(), |
| 45 | "yolo" | "4" | "bypass" | "bypass-permissions" | "bypasspermissions" |
| 46 | ) |
| 47 | } |
| 48 | |
| 49 | #[must_use] |
| 50 | pub fn from_setting(value: &str) -> Self { |
| 51 | // Unreleased Multitask never shipped; normalize leftover settings to Operate. |
| 52 | match value.trim().to_ascii_lowercase().as_str() { |
| 53 | "multitask" | "multi" | "5" => Self::Operate, |
| 54 | other => Self::parse(other).unwrap_or(Self::Agent), |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | #[must_use] |
| 59 | pub fn as_setting(self) -> &'static str { |
| 60 | match self { |
| 61 | Self::Agent => "agent", |
| 62 | Self::Plan => "plan", |
| 63 | Self::Operate => "operate", |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /// Short label used in the UI footer. |
| 68 | pub fn label(self) -> &'static str { |
| 69 | match self { |
| 70 | AppMode::Agent => "ACT", |
| 71 | AppMode::Plan => "PLAN", |
| 72 | AppMode::Operate => "OPERATE", |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | #[must_use] |
| 77 | pub fn display_name(self) -> &'static str { |
| 78 | match self { |
| 79 | AppMode::Agent => "Act", |
| 80 | AppMode::Plan => "Plan", |
| 81 | AppMode::Operate => "Operate", |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | #[must_use] |
| 86 | pub fn number(self) -> char { |
| 87 | match self { |
| 88 | AppMode::Agent => '1', |
| 89 | AppMode::Plan => '2', |
| 90 | AppMode::Operate => '3', |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | #[must_use] |
| 95 | pub fn uses_agent_baseline(self) -> bool { |
| 96 | matches!(self, Self::Agent | Self::Operate) |
| 97 | } |
| 98 | |
| 99 | /// Operate gets a higher parallel launch floor so background fan-out is |
| 100 | /// not throttled to a single slot when config is low. |
| 101 | #[must_use] |
| 102 | pub fn mode_delegation_launch_floor(self) -> usize { |
| 103 | match self { |
| 104 | Self::Operate => 4, |
| 105 | _ => 1, |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// Description shown in help or onboarding text. |
| 110 | pub fn description(self) -> &'static str { |
| 111 | match self { |
| 112 | AppMode::Agent => "Act mode - direct work in the current session with tools", |
| 113 | AppMode::Plan => "Plan mode - research and design before implementing", |
| 114 | AppMode::Operate => { |
| 115 | "Operate mode - always-on fleet operation: lead plans, optional $/time burn rate, workers follow the plan" |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | #[must_use] |
| 121 | pub fn next(self) -> Self { |
| 122 | let Some(index) = Self::CYCLE.iter().position(|mode| *mode == self) else { |
| 123 | return Self::Agent; |
| 124 | }; |
| 125 | Self::CYCLE[(index + 1) % Self::CYCLE.len()] |
| 126 | } |
| 127 | |
| 128 | #[must_use] |
| 129 | pub fn previous(self) -> Self { |
| 130 | let Some(index) = Self::CYCLE.iter().position(|mode| *mode == self) else { |
| 131 | return Self::Agent; |
| 132 | }; |
| 133 | Self::CYCLE[(index + Self::CYCLE.len() - 1) % Self::CYCLE.len()] |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | #[cfg(test)] |
| 138 | mod tests { |
| 139 | use super::*; |
| 140 | |
| 141 | #[test] |
| 142 | fn app_mode_helpers_centralize_parse_labels_and_cycle_order() { |
| 143 | assert_eq!(AppMode::parse("agent"), Some(AppMode::Agent)); |
| 144 | assert_eq!(AppMode::parse("act"), Some(AppMode::Agent)); |
| 145 | assert_eq!(AppMode::parse("work"), Some(AppMode::Agent)); |
| 146 | assert_eq!(AppMode::parse("2"), Some(AppMode::Plan)); |
| 147 | assert_eq!(AppMode::parse("auto"), Some(AppMode::Agent)); |
| 148 | assert_eq!(AppMode::parse("3"), Some(AppMode::Operate)); |
| 149 | assert_eq!(AppMode::parse("operate"), Some(AppMode::Operate)); |
| 150 | // Legacy YOLO spellings resolve to Act; the bypass posture they imply |
| 151 | // travels on the permission surface, not on a mode. |
| 152 | assert_eq!(AppMode::parse("YOLO"), Some(AppMode::Agent)); |
| 153 | assert_eq!(AppMode::parse("4"), Some(AppMode::Agent)); |
| 154 | assert_eq!(AppMode::parse("bypass"), Some(AppMode::Agent)); |
| 155 | assert_eq!(AppMode::parse("bypass-permissions"), Some(AppMode::Agent)); |
| 156 | for alias in [ |
| 157 | "yolo", |
| 158 | " YOLO ", |
| 159 | "4", |
| 160 | "bypass", |
| 161 | "bypass-permissions", |
| 162 | "BypassPermissions", |
| 163 | ] { |
| 164 | assert!(AppMode::is_legacy_bypass_alias(alias), "{alias}"); |
| 165 | } |
| 166 | for mode in ["agent", "act", "plan", "operate", "1", "full-access", ""] { |
| 167 | assert!(!AppMode::is_legacy_bypass_alias(mode), "{mode}"); |
| 168 | } |
| 169 | assert_eq!(AppMode::parse("multitask"), None); |
| 170 | assert_eq!(AppMode::parse("5"), None); |
| 171 | assert_eq!(AppMode::parse("fast"), None); |
| 172 | assert_eq!(AppMode::from_setting("multitask"), AppMode::Operate); |
| 173 | assert_eq!(AppMode::from_setting("5"), AppMode::Operate); |
| 174 | |
| 175 | assert_eq!(AppMode::Agent.as_setting(), "agent"); |
| 176 | assert_eq!(AppMode::Plan.display_name(), "Plan"); |
| 177 | assert_eq!(AppMode::Agent.number(), '1'); |
| 178 | assert_eq!(AppMode::Operate.number(), '3'); |
| 179 | assert_eq!( |
| 180 | AppMode::CYCLE, |
| 181 | [AppMode::Plan, AppMode::Agent, AppMode::Operate] |
| 182 | ); |
| 183 | |
| 184 | assert_eq!(AppMode::Plan.next(), AppMode::Agent); |
| 185 | assert_eq!(AppMode::Agent.next(), AppMode::Operate); |
| 186 | assert_eq!(AppMode::Operate.next(), AppMode::Plan); |
| 187 | assert_eq!(AppMode::Plan.previous(), AppMode::Operate); |
| 188 | assert_eq!(AppMode::Agent.previous(), AppMode::Plan); |
| 189 | assert_eq!(AppMode::Operate.previous(), AppMode::Agent); |
| 190 | } |
| 191 | |
| 192 | /// The durable form of a mode is the `as_setting()` string persisted into |
| 193 | /// settings and session records — `AppMode` derives no `Serialize`, so the |
| 194 | /// round-trip that has to hold is string -> mode -> string. |
| 195 | #[test] |
| 196 | fn setting_strings_round_trip_for_every_mode() { |
| 197 | for mode in AppMode::CYCLE { |
| 198 | let setting = mode.as_setting(); |
| 199 | assert_eq!( |
| 200 | AppMode::from_setting(setting), |
| 201 | mode, |
| 202 | "from_setting({setting})" |
| 203 | ); |
| 204 | assert_eq!(AppMode::parse(setting), Some(mode), "parse({setting})"); |
| 205 | } |
| 206 | |
| 207 | assert_eq!(AppMode::Agent.as_setting(), "agent"); |
| 208 | assert_eq!(AppMode::Plan.as_setting(), "plan"); |
| 209 | assert_eq!(AppMode::Operate.as_setting(), "operate"); |
| 210 | } |
| 211 | |
| 212 | /// `from_setting` is the de-facto default: an absent, empty, or unreadable |
| 213 | /// stored value must land on Act rather than panicking or picking Operate. |
| 214 | #[test] |
| 215 | fn from_setting_falls_back_to_act_for_unknown_values() { |
| 216 | assert_eq!(AppMode::from_setting(""), AppMode::Agent); |
| 217 | assert_eq!(AppMode::from_setting(" "), AppMode::Agent); |
| 218 | assert_eq!(AppMode::from_setting("nonsense"), AppMode::Agent); |
| 219 | assert_eq!(AppMode::from_setting("OPERATE"), AppMode::Operate); |
| 220 | } |
| 221 | |
| 222 | #[test] |
| 223 | fn labels_and_descriptions_cover_every_mode() { |
| 224 | assert_eq!(AppMode::Agent.label(), "ACT"); |
| 225 | assert_eq!(AppMode::Plan.label(), "PLAN"); |
| 226 | assert_eq!(AppMode::Operate.label(), "OPERATE"); |
| 227 | |
| 228 | assert_eq!(AppMode::Agent.display_name(), "Act"); |
| 229 | assert_eq!(AppMode::Plan.display_name(), "Plan"); |
| 230 | assert_eq!(AppMode::Operate.display_name(), "Operate"); |
| 231 | |
| 232 | for mode in AppMode::CYCLE { |
| 233 | assert!(!mode.description().is_empty()); |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | #[test] |
| 238 | fn operate_shares_the_agent_baseline_and_raises_the_launch_floor() { |
| 239 | assert!(AppMode::Agent.uses_agent_baseline()); |
| 240 | assert!(AppMode::Operate.uses_agent_baseline()); |
| 241 | assert!(!AppMode::Plan.uses_agent_baseline()); |
| 242 | |
| 243 | assert_eq!(AppMode::Operate.mode_delegation_launch_floor(), 4); |
| 244 | assert_eq!(AppMode::Agent.mode_delegation_launch_floor(), 1); |
| 245 | assert_eq!(AppMode::Plan.mode_delegation_launch_floor(), 1); |
| 246 | } |
| 247 | } |
| 248 |