| 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 | "yolo" | "4" | "bypass" | "bypass-permissions" | "bypasspermissions" => { |
| 32 | Some(Self::Agent) |
| 33 | } |
| 34 | _ => None, |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | #[must_use] |
| 39 | pub fn from_setting(value: &str) -> Self { |
| 40 | // Unreleased Multitask never shipped; normalize leftover settings to Operate. |
| 41 | match value.trim().to_ascii_lowercase().as_str() { |
| 42 | "multitask" | "multi" | "5" => Self::Operate, |
| 43 | other => Self::parse(other).unwrap_or(Self::Agent), |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | #[must_use] |
| 48 | pub fn as_setting(self) -> &'static str { |
| 49 | match self { |
| 50 | Self::Agent => "agent", |
| 51 | Self::Plan => "plan", |
| 52 | Self::Operate => "operate", |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /// Short label used in the UI footer. |
| 57 | pub fn label(self) -> &'static str { |
| 58 | match self { |
| 59 | AppMode::Agent => "ACT", |
| 60 | AppMode::Plan => "PLAN", |
| 61 | AppMode::Operate => "OPERATE", |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | #[must_use] |
| 66 | pub fn display_name(self) -> &'static str { |
| 67 | match self { |
| 68 | AppMode::Agent => "Act", |
| 69 | AppMode::Plan => "Plan", |
| 70 | AppMode::Operate => "Operate", |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | #[must_use] |
| 75 | pub fn number(self) -> char { |
| 76 | match self { |
| 77 | AppMode::Agent => '1', |
| 78 | AppMode::Plan => '2', |
| 79 | AppMode::Operate => '3', |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | #[must_use] |
| 84 | pub fn uses_agent_baseline(self) -> bool { |
| 85 | matches!(self, Self::Agent | Self::Operate) |
| 86 | } |
| 87 | |
| 88 | /// Operate gets a higher parallel launch floor so background fan-out is |
| 89 | /// not throttled to a single slot when config is low. |
| 90 | #[must_use] |
| 91 | pub fn mode_delegation_launch_floor(self) -> usize { |
| 92 | match self { |
| 93 | Self::Operate => 4, |
| 94 | _ => 1, |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | /// Description shown in help or onboarding text. |
| 99 | pub fn description(self) -> &'static str { |
| 100 | match self { |
| 101 | AppMode::Agent => "Act mode - direct work in the current session with tools", |
| 102 | AppMode::Plan => "Plan mode - research and design before implementing", |
| 103 | AppMode::Operate => { |
| 104 | "Operate mode - always-on fleet operation: lead plans, optional $/time burn rate, workers follow the plan" |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | #[must_use] |
| 110 | pub fn next(self) -> Self { |
| 111 | let Some(index) = Self::CYCLE.iter().position(|mode| *mode == self) else { |
| 112 | return Self::Agent; |
| 113 | }; |
| 114 | Self::CYCLE[(index + 1) % Self::CYCLE.len()] |
| 115 | } |
| 116 | |
| 117 | #[must_use] |
| 118 | pub fn previous(self) -> Self { |
| 119 | let Some(index) = Self::CYCLE.iter().position(|mode| *mode == self) else { |
| 120 | return Self::Agent; |
| 121 | }; |
| 122 | Self::CYCLE[(index + Self::CYCLE.len() - 1) % Self::CYCLE.len()] |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | #[cfg(test)] |
| 127 | mod tests { |
| 128 | use super::*; |
| 129 | |
| 130 | #[test] |
| 131 | fn app_mode_helpers_centralize_parse_labels_and_cycle_order() { |
| 132 | assert_eq!(AppMode::parse("agent"), Some(AppMode::Agent)); |
| 133 | assert_eq!(AppMode::parse("act"), Some(AppMode::Agent)); |
| 134 | assert_eq!(AppMode::parse("work"), Some(AppMode::Agent)); |
| 135 | assert_eq!(AppMode::parse("2"), Some(AppMode::Plan)); |
| 136 | assert_eq!(AppMode::parse("auto"), Some(AppMode::Agent)); |
| 137 | assert_eq!(AppMode::parse("3"), Some(AppMode::Operate)); |
| 138 | assert_eq!(AppMode::parse("operate"), Some(AppMode::Operate)); |
| 139 | // Legacy YOLO spellings resolve to Act; the bypass posture they imply |
| 140 | // travels on the permission surface, not on a mode. |
| 141 | assert_eq!(AppMode::parse("YOLO"), Some(AppMode::Agent)); |
| 142 | assert_eq!(AppMode::parse("4"), Some(AppMode::Agent)); |
| 143 | assert_eq!(AppMode::parse("bypass"), Some(AppMode::Agent)); |
| 144 | assert_eq!(AppMode::parse("bypass-permissions"), Some(AppMode::Agent)); |
| 145 | assert_eq!(AppMode::parse("multitask"), None); |
| 146 | assert_eq!(AppMode::parse("5"), None); |
| 147 | assert_eq!(AppMode::parse("fast"), None); |
| 148 | assert_eq!(AppMode::from_setting("multitask"), AppMode::Operate); |
| 149 | assert_eq!(AppMode::from_setting("5"), AppMode::Operate); |
| 150 | |
| 151 | assert_eq!(AppMode::Agent.as_setting(), "agent"); |
| 152 | assert_eq!(AppMode::Plan.display_name(), "Plan"); |
| 153 | assert_eq!(AppMode::Agent.number(), '1'); |
| 154 | assert_eq!(AppMode::Operate.number(), '3'); |
| 155 | assert_eq!( |
| 156 | AppMode::CYCLE, |
| 157 | [AppMode::Plan, AppMode::Agent, AppMode::Operate] |
| 158 | ); |
| 159 | |
| 160 | assert_eq!(AppMode::Plan.next(), AppMode::Agent); |
| 161 | assert_eq!(AppMode::Agent.next(), AppMode::Operate); |
| 162 | assert_eq!(AppMode::Operate.next(), AppMode::Plan); |
| 163 | assert_eq!(AppMode::Plan.previous(), AppMode::Operate); |
| 164 | assert_eq!(AppMode::Agent.previous(), AppMode::Plan); |
| 165 | assert_eq!(AppMode::Operate.previous(), AppMode::Agent); |
| 166 | } |
| 167 | |
| 168 | /// The durable form of a mode is the `as_setting()` string persisted into |
| 169 | /// settings and session records — `AppMode` derives no `Serialize`, so the |
| 170 | /// round-trip that has to hold is string -> mode -> string. |
| 171 | #[test] |
| 172 | fn setting_strings_round_trip_for_every_mode() { |
| 173 | for mode in AppMode::CYCLE { |
| 174 | let setting = mode.as_setting(); |
| 175 | assert_eq!( |
| 176 | AppMode::from_setting(setting), |
| 177 | mode, |
| 178 | "from_setting({setting})" |
| 179 | ); |
| 180 | assert_eq!(AppMode::parse(setting), Some(mode), "parse({setting})"); |
| 181 | } |
| 182 | |
| 183 | assert_eq!(AppMode::Agent.as_setting(), "agent"); |
| 184 | assert_eq!(AppMode::Plan.as_setting(), "plan"); |
| 185 | assert_eq!(AppMode::Operate.as_setting(), "operate"); |
| 186 | } |
| 187 | |
| 188 | /// `from_setting` is the de-facto default: an absent, empty, or unreadable |
| 189 | /// stored value must land on Act rather than panicking or picking Operate. |
| 190 | #[test] |
| 191 | fn from_setting_falls_back_to_act_for_unknown_values() { |
| 192 | assert_eq!(AppMode::from_setting(""), AppMode::Agent); |
| 193 | assert_eq!(AppMode::from_setting(" "), AppMode::Agent); |
| 194 | assert_eq!(AppMode::from_setting("nonsense"), AppMode::Agent); |
| 195 | assert_eq!(AppMode::from_setting("OPERATE"), AppMode::Operate); |
| 196 | } |
| 197 | |
| 198 | #[test] |
| 199 | fn labels_and_descriptions_cover_every_mode() { |
| 200 | assert_eq!(AppMode::Agent.label(), "ACT"); |
| 201 | assert_eq!(AppMode::Plan.label(), "PLAN"); |
| 202 | assert_eq!(AppMode::Operate.label(), "OPERATE"); |
| 203 | |
| 204 | assert_eq!(AppMode::Agent.display_name(), "Act"); |
| 205 | assert_eq!(AppMode::Plan.display_name(), "Plan"); |
| 206 | assert_eq!(AppMode::Operate.display_name(), "Operate"); |
| 207 | |
| 208 | for mode in AppMode::CYCLE { |
| 209 | assert!(!mode.description().is_empty()); |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | #[test] |
| 214 | fn operate_shares_the_agent_baseline_and_raises_the_launch_floor() { |
| 215 | assert!(AppMode::Agent.uses_agent_baseline()); |
| 216 | assert!(AppMode::Operate.uses_agent_baseline()); |
| 217 | assert!(!AppMode::Plan.uses_agent_baseline()); |
| 218 | |
| 219 | assert_eq!(AppMode::Operate.mode_delegation_launch_floor(), 4); |
| 220 | assert_eq!(AppMode::Agent.mode_delegation_launch_floor(), 1); |
| 221 | assert_eq!(AppMode::Plan.mode_delegation_launch_floor(), 1); |
| 222 | } |
| 223 | } |
| 224 |