返回 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 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
248 lines RUST