claude_code_cli_acp/config/
session.rs1use agent_client_protocol::schema::{
2 ConfigOptionUpdate, CurrentModeUpdate, ModelInfo, SessionConfigOption,
3 SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId, SessionMode,
4 SessionModeState, SessionModelState, SessionUpdate,
5};
6
7use crate::config::settings::ClaudeSettings;
8
9const DEFAULT_MODEL_ID: &str = "default";
10const DEFAULT_EFFORT: &str = "high";
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct SessionConfigState {
14 mode: String,
15 model: String,
16 effort: String,
17 models: Vec<ModelChoice>,
18 allow_bypass: bool,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct ModelChoice {
23 pub id: String,
24 pub name: String,
25 pub description: Option<String>,
26}
27
28impl SessionConfigState {
29 pub fn from_settings(settings: &ClaudeSettings) -> Self {
30 let allow_bypass = allow_bypass_permissions();
31 let mut models = configured_models(settings.available_models.as_deref());
32 let model = resolve_model(
33 &models,
34 std::env::var("ANTHROPIC_MODEL")
35 .ok()
36 .as_deref()
37 .or(settings.model.as_deref()),
38 )
39 .unwrap_or_else(|| DEFAULT_MODEL_ID.to_string());
40 if !models.iter().any(|choice| choice.id == model) {
41 models.push(ModelChoice {
42 id: model.clone(),
43 name: model.clone(),
44 description: None,
45 });
46 }
47 let mode =
48 resolve_permission_mode(settings.permissions.default_mode.as_deref(), allow_bypass);
49 let effort = normalize_effort(settings.effort_level.as_deref())
50 .unwrap_or_else(|| DEFAULT_EFFORT.to_string());
51
52 Self {
53 mode,
54 model,
55 effort,
56 models,
57 allow_bypass,
58 }
59 }
60
61 pub fn mode(&self) -> &str {
62 &self.mode
63 }
64
65 pub fn model(&self) -> &str {
66 &self.model
67 }
68
69 pub fn effort(&self) -> &str {
70 &self.effort
71 }
72
73 pub fn modes(&self) -> SessionModeState {
74 SessionModeState::new(self.mode.clone(), available_modes(self.allow_bypass))
75 }
76
77 pub fn models(&self) -> SessionModelState {
78 SessionModelState::new(
79 self.model.clone(),
80 self.models
81 .iter()
82 .map(|choice| {
83 ModelInfo::new(choice.id.clone(), choice.name.clone())
84 .description(choice.description.clone())
85 })
86 .collect(),
87 )
88 }
89
90 pub fn config_options(&self) -> Vec<SessionConfigOption> {
91 vec![
92 SessionConfigOption::select(
93 "mode",
94 "Mode",
95 self.mode.clone(),
96 available_modes(self.allow_bypass)
97 .into_iter()
98 .map(|mode| {
99 SessionConfigSelectOption::new(mode.id.0.to_string(), mode.name)
100 .description(mode.description)
101 })
102 .collect::<Vec<_>>(),
103 )
104 .description("Session permission mode")
105 .category(SessionConfigOptionCategory::Mode),
106 SessionConfigOption::select(
107 "model",
108 "Model",
109 self.model.clone(),
110 self.models
111 .iter()
112 .map(|choice| {
113 SessionConfigSelectOption::new(choice.id.clone(), choice.name.clone())
114 .description(choice.description.clone())
115 })
116 .collect::<Vec<_>>(),
117 )
118 .description("Claude model to use")
119 .category(SessionConfigOptionCategory::Model),
120 SessionConfigOption::select(
121 "effort",
122 "Effort",
123 self.effort.clone(),
124 ["low", "medium", "high", "xhigh", "max"]
125 .into_iter()
126 .map(|level| SessionConfigSelectOption::new(level, effort_label(level)))
127 .collect::<Vec<_>>(),
128 )
129 .description("Claude reasoning effort level")
130 .category(SessionConfigOptionCategory::ThoughtLevel),
131 ]
132 }
133
134 pub fn set_mode(&mut self, mode: &str) -> anyhow::Result<()> {
135 let resolved = resolve_permission_mode(Some(mode), self.allow_bypass);
136 if resolved == "default" && !mode.trim().eq_ignore_ascii_case("default") {
137 anyhow::bail!("invalid permission mode: {mode}");
138 }
139 self.mode = resolved;
140 Ok(())
141 }
142
143 pub fn set_model(&mut self, model: &str) -> anyhow::Result<String> {
144 let Some(resolved) = resolve_model(&self.models, Some(model)) else {
145 anyhow::bail!("invalid model: {model}");
146 };
147 self.model = resolved.clone();
148 Ok(resolved)
149 }
150
151 pub fn set_effort(&mut self, effort: &str) -> anyhow::Result<()> {
152 let Some(resolved) = normalize_effort(Some(effort)) else {
153 anyhow::bail!("invalid effort: {effort}");
154 };
155 self.effort = resolved;
156 Ok(())
157 }
158
159 pub fn set_option(
160 &mut self,
161 config_id: &str,
162 value: &SessionConfigValueId,
163 ) -> anyhow::Result<Option<SessionUpdate>> {
164 match config_id {
165 "mode" => {
166 self.set_mode(value.0.as_ref())?;
167 Ok(Some(SessionUpdate::CurrentModeUpdate(
168 CurrentModeUpdate::new(self.mode.clone()),
169 )))
170 }
171 "model" => {
172 self.set_model(value.0.as_ref())?;
173 Ok(Some(SessionUpdate::ConfigOptionUpdate(
174 ConfigOptionUpdate::new(self.config_options()),
175 )))
176 }
177 "effort" => {
178 self.set_effort(value.0.as_ref())?;
179 Ok(Some(SessionUpdate::ConfigOptionUpdate(
180 ConfigOptionUpdate::new(self.config_options()),
181 )))
182 }
183 _ => anyhow::bail!("unknown config option: {config_id}"),
184 }
185 }
186}
187
188pub fn resolve_permission_mode(default_mode: Option<&str>, allow_bypass: bool) -> String {
189 let Some(default_mode) = default_mode else {
190 return "default".to_string();
191 };
192 let normalized = default_mode.trim().to_ascii_lowercase();
193 match normalized.as_str() {
194 "auto" => "auto".to_string(),
195 "default" => "default".to_string(),
196 "acceptedits" => "acceptEdits".to_string(),
197 "dontask" => "dontAsk".to_string(),
198 "plan" => "plan".to_string(),
199 "bypasspermissions" | "bypass" if allow_bypass => "bypassPermissions".to_string(),
200 _ => "default".to_string(),
201 }
202}
203
204pub fn available_modes(allow_bypass: bool) -> Vec<SessionMode> {
205 let mut modes = vec![
206 SessionMode::new("auto", "Auto")
207 .description("Use Claude's auto mode for permission prompts"),
208 SessionMode::new("default", "Default")
209 .description("Standard behavior, prompts for dangerous operations"),
210 SessionMode::new("acceptEdits", "Accept Edits")
211 .description("Auto-accept file edit operations"),
212 SessionMode::new("plan", "Plan Mode").description("Planning mode, no tool execution"),
213 SessionMode::new("dontAsk", "Don't Ask")
214 .description("Deny unapproved permission prompts instead of asking"),
215 ];
216 if allow_bypass {
217 modes.push(
218 SessionMode::new("bypassPermissions", "Bypass Permissions")
219 .description("Bypass all permission checks"),
220 );
221 }
222 modes
223}
224
225pub fn allow_bypass_permissions() -> bool {
226 !running_as_root() || std::env::var_os("IS_SANDBOX").is_some()
227}
228
229fn configured_models(allowlist: Option<&[String]>) -> Vec<ModelChoice> {
230 let mut models = vec![ModelChoice {
231 id: DEFAULT_MODEL_ID.to_string(),
232 name: "Default".to_string(),
233 description: Some("Claude Code default model".to_string()),
234 }];
235
236 let configured = allowlist
237 .map(|models| models.to_vec())
238 .unwrap_or_else(|| vec!["sonnet".to_string(), "opus".to_string()]);
239 for model in configured {
240 let trimmed = model.trim();
241 if trimmed.is_empty() || models.iter().any(|choice| choice.id == trimmed) {
242 continue;
243 }
244 models.push(ModelChoice {
245 id: trimmed.to_string(),
246 name: model_label(trimmed),
247 description: None,
248 });
249 }
250 models
251}
252
253fn resolve_model(models: &[ModelChoice], preference: Option<&str>) -> Option<String> {
254 let preference = preference?.trim();
255 if preference.is_empty() {
256 return None;
257 }
258 let lower = preference.to_ascii_lowercase();
259 models
260 .iter()
261 .find(|model| {
262 let id = model.id.to_ascii_lowercase();
263 let name = model.name.to_ascii_lowercase();
264 id == lower || name == lower || id.contains(&lower) || lower.contains(&id)
265 })
266 .map(|model| model.id.clone())
267}
268
269fn normalize_effort(effort: Option<&str>) -> Option<String> {
270 match effort?.trim().to_ascii_lowercase().as_str() {
271 "low" => Some("low".to_string()),
272 "medium" => Some("medium".to_string()),
273 "high" => Some("high".to_string()),
274 "xhigh" | "extra-high" | "extra_high" => Some("xhigh".to_string()),
275 "max" => Some("max".to_string()),
276 _ => None,
277 }
278}
279
280fn model_label(model: &str) -> String {
281 if model == DEFAULT_MODEL_ID {
282 return "Default".to_string();
283 }
284 let mut chars = model.chars();
285 match chars.next() {
286 Some(first) => first.to_uppercase().chain(chars).collect(),
287 None => model.to_string(),
288 }
289}
290
291fn effort_label(level: &str) -> String {
292 match level {
293 "xhigh" => "XHigh".to_string(),
294 _ => model_label(level),
295 }
296}
297
298#[cfg(unix)]
299fn running_as_root() -> bool {
300 unsafe extern "C" {
301 fn geteuid() -> u32;
302 }
303 unsafe { geteuid() == 0 }
304}
305
306#[cfg(not(unix))]
307fn running_as_root() -> bool {
308 false
309}