返回 CodeWhale
app_mode.rs
根目录 / crates / config / src / app_mode.rs
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
224 lines RUST