1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::{collections::BTreeMap, num::NonZeroUsize, path::PathBuf};
4
5use super::helpers::is_false;
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
8pub struct SideAgentSettings {
9 #[serde(default, skip_serializing_if = "Option::is_none")]
10 pub provider: Option<String>,
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub model: Option<String>,
13}
14
15impl SideAgentSettings {
16 pub(crate) fn is_default(&self) -> bool {
17 self == &Self::default()
18 }
19
20 pub(crate) fn validate(&self) -> anyhow::Result<()> {
21 anyhow::ensure!(
22 self.provider.is_some() == self.model.is_some(),
23 "agent.side.provider and agent.side.model must both be configured, or both omitted to inherit the primary provider/model"
24 );
25 for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
26 if let Some(value) = value {
27 super::tools::validate_model_identifier(&format!("agent.side.{field}"), value)?;
28 }
29 }
30 Ok(())
31 }
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
35pub struct SubagentsSettings {
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub disabled: Vec<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub schema_validation_max_retries: Option<u64>,
40 #[serde(default, flatten)]
41 pub(crate) extra: BTreeMap<String, serde_json::Value>,
42}
43
44impl SubagentsSettings {
45 pub(crate) fn schema_validation_max_retries(&self) -> u64 {
46 self.schema_validation_max_retries
47 .unwrap_or(DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES)
48 }
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
52pub struct ModelsSettings {
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub disabled: Vec<String>,
55 #[serde(default, flatten)]
56 pub(crate) extra: BTreeMap<String, serde_json::Value>,
57}
58
59impl ModelsSettings {
60 pub(crate) fn is_default(&self) -> bool {
61 self == &Self::default()
62 }
63}
64
65pub const DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 2;
66pub const MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 5;
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
69pub struct IntegrationsSettings {
70 #[serde(default, skip_serializing_if = "HerdrSettings::is_default")]
71 pub herdr: HerdrSettings,
72 #[serde(default, flatten)]
73 pub(crate) extra: BTreeMap<String, serde_json::Value>,
74}
75
76impl IntegrationsSettings {
77 pub(crate) fn is_default(&self) -> bool {
78 self == &Self::default()
79 }
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
83pub struct HerdrSettings {
84 #[serde(default)]
85 pub enabled: bool,
86 #[serde(default, flatten)]
87 pub(crate) extra: BTreeMap<String, serde_json::Value>,
88}
89
90impl HerdrSettings {
91 pub(crate) fn is_default(&self) -> bool {
92 self == &Self::default()
93 }
94}
95
96pub const DEFAULT_TUI_SUBAGENT_CARD_ROWS: usize = 16;
97pub const MIN_TUI_SUBAGENT_CARD_ROWS: usize = 1;
98pub const MAX_TUI_SUBAGENT_CARD_ROWS: usize = 50;
99
100#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
101#[serde(rename_all = "snake_case")]
102pub(crate) enum HeaderPanel {
103 Summary,
104 ActivityTree,
105 SessionFiles,
106}
107
108#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
109#[serde(default)]
110pub(crate) struct PanelLayoutSettings {
111 pub(crate) open: Vec<HeaderPanel>,
112 pub(crate) pinned: bool,
113 pub(crate) opened_by_pin: bool,
114 #[serde(flatten)]
115 pub(crate) extra: BTreeMap<String, serde_json::Value>,
116}
117
118impl PanelLayoutSettings {
119 fn is_default(&self) -> bool {
120 self == &Self::default()
121 }
122}
123
124#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
125pub struct TuiSettings {
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 #[schemars(range(min = 1, max = 50))]
128 pub(crate) subagent_card_rows: Option<usize>,
129 #[serde(default, skip_serializing_if = "PanelLayoutSettings::is_default")]
130 pub(crate) panel_layout: PanelLayoutSettings,
131 #[serde(default, flatten)]
132 pub(crate) extra: BTreeMap<String, serde_json::Value>,
133}
134
135impl TuiSettings {
136 pub(crate) fn is_default(&self) -> bool {
137 self == &Self::default()
138 }
139
140 pub(crate) fn subagent_card_rows(&self) -> usize {
141 self.subagent_card_rows
142 .unwrap_or(DEFAULT_TUI_SUBAGENT_CARD_ROWS)
143 }
144}
145
146#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
147pub struct InstructionsSettings {
148 #[serde(default, skip_serializing_if = "is_false")]
149 pub subdir_discovery: bool,
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub additional_markdown_paths: Vec<PathBuf>,
152 #[serde(default, flatten)]
153 pub(crate) extra: BTreeMap<String, serde_json::Value>,
154}
155
156impl InstructionsSettings {
157 pub(crate) fn is_default(&self) -> bool {
158 self == &Self::default()
159 }
160}
161
162#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
163pub struct SkillsSettings {
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub additional_paths: Vec<PathBuf>,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub disabled: Vec<String>,
168 #[serde(default, flatten)]
169 pub(crate) extra: BTreeMap<String, serde_json::Value>,
170}
171
172impl SkillsSettings {
173 pub(crate) fn is_default(&self) -> bool {
174 self == &Self::default()
175 }
176}
177
178pub(crate) const DEFAULT_AUTO_COMPACTIONS_PER_RUN: u8 = 4;
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub(crate) enum AutoCompactionLimit {
182 Limited(NonZeroUsize),
183 NoCountCap,
184}
185
186impl AutoCompactionLimit {
187 pub(crate) fn allows(self, completed_compactions: usize) -> bool {
188 match self {
189 Self::Limited(limit) => completed_compactions < limit.get(),
190 Self::NoCountCap => true,
191 }
192 }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
196#[serde(default)]
197pub struct AutoCompactionSettings {
198 pub enabled: bool,
199 pub threshold_percent: Option<u8>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub threshold_tokens: Option<u64>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 #[schemars(
204 description = "Maximum automatic compactions per run. When omitted, the effective runtime default is 4. Set to 0 to remove only this per-run compaction-count cap.",
205 extend("default" = DEFAULT_AUTO_COMPACTIONS_PER_RUN)
206 )]
207 pub max_compactions_per_run: Option<u8>,
208}
209
210impl Default for AutoCompactionSettings {
211 fn default() -> Self {
212 Self {
213 enabled: true,
214 threshold_percent: Some(80),
215 threshold_tokens: None,
216 max_compactions_per_run: None,
217 }
218 }
219}
220
221impl AutoCompactionSettings {
222 pub(crate) fn is_enabled(&self) -> bool {
223 self.enabled
224 }
225
226 pub(crate) fn compaction_limit(&self) -> AutoCompactionLimit {
227 let configured = self
228 .max_compactions_per_run
229 .unwrap_or(DEFAULT_AUTO_COMPACTIONS_PER_RUN);
230 NonZeroUsize::new(usize::from(configured)).map_or(
231 AutoCompactionLimit::NoCountCap,
232 AutoCompactionLimit::Limited,
233 )
234 }
235
236 fn is_default(&self) -> bool {
237 self == &Self::default()
238 }
239
240 pub(crate) fn triggered(&self, projected_tokens: u64, model_max_tokens: u64) -> bool {
241 if !self.enabled {
242 return false;
243 }
244 self.threshold_percent.is_some_and(|percent| {
245 u128::from(projected_tokens) * 100 >= u128::from(model_max_tokens) * u128::from(percent)
246 }) || self
247 .threshold_tokens
248 .is_some_and(|threshold| projected_tokens >= threshold)
249 }
250
251 pub(crate) fn threshold_display(&self) -> Option<String> {
252 match (self.threshold_percent, self.threshold_tokens) {
253 (Some(percent), Some(tokens)) => Some(format!("{percent}% or {tokens} tokens")),
254 (Some(percent), None) => Some(format!("{percent}%")),
255 (None, Some(tokens)) => Some(format!("{tokens} tokens")),
256 (None, None) => None,
257 }
258 }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
262#[serde(default)]
263pub struct ClaudeSubscriptionCompactionSettings {
264 pub allow_compaction: bool,
265 pub reserve_tokens: usize,
266 pub threshold_percent: Option<u8>,
267 pub threshold_tokens: Option<u64>,
268}
269
270impl Default for ClaudeSubscriptionCompactionSettings {
271 fn default() -> Self {
272 Self {
273 allow_compaction: true,
274 reserve_tokens: 16_384,
275 threshold_percent: Some(100),
276 threshold_tokens: Some(crate::providers::CLAUDE_SUBSCRIPTION_MAX_PROMPT_TOKENS as u64),
277 }
278 }
279}
280
281impl ClaudeSubscriptionCompactionSettings {
282 fn is_default(&self) -> bool {
283 self == &Self::default()
284 }
285}
286
287#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
288pub struct CompactionSettings {
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub provider: Option<String>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub model: Option<String>,
293 #[serde(default, skip_serializing_if = "AutoCompactionSettings::is_default")]
294 pub auto: AutoCompactionSettings,
295 #[serde(
296 default,
297 skip_serializing_if = "ClaudeSubscriptionCompactionSettings::is_default"
298 )]
299 pub claude_subscription: ClaudeSubscriptionCompactionSettings,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub(crate) struct CompactionConfig {
304 pub(crate) provider: String,
305 pub(crate) model: String,
306}
307
308impl CompactionSettings {
309 fn claude_subscription_compaction_reserve(&self) -> usize {
310 let compacts_with_subscription = self.claude_subscription.allow_compaction
311 && self
312 .provider
313 .as_deref()
314 .map(str::trim)
315 .unwrap_or(crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER)
316 == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER;
317 if compacts_with_subscription {
318 self.claude_subscription.reserve_tokens
319 } else {
320 0
321 }
322 }
323
324 pub(crate) fn claude_subscription_context_limit(&self) -> usize {
325 crate::providers::CLAUDE_SUBSCRIPTION_MAX_PROMPT_TOKENS
326 .saturating_sub(self.claude_subscription_compaction_reserve())
327 }
328
329 pub(crate) fn apply_conversation_limit(
330 &self,
331 budget: &mut crate::context::ContextBudget,
332 provider: &str,
333 ) {
334 if provider == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
335 budget.max_tokens = budget
336 .max_tokens
337 .min(crate::providers::CLAUDE_SUBSCRIPTION_MAX_PROMPT_TOKENS)
338 .saturating_sub(self.claude_subscription_compaction_reserve());
339 }
340 }
341
342 pub(crate) fn auto_for_provider(
343 &self,
344 provider: &str,
345 max_tokens: usize,
346 ) -> AutoCompactionSettings {
347 if provider != crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
348 return self.auto.clone();
349 }
350 let subscription = &self.claude_subscription;
351 let ceiling = self.claude_subscription_context_limit().min(max_tokens) as u64;
352 AutoCompactionSettings {
353 enabled: true,
354 threshold_percent: subscription.threshold_percent,
355 threshold_tokens: Some(
356 subscription
357 .threshold_tokens
358 .unwrap_or(ceiling)
359 .min(ceiling),
360 ),
361 max_compactions_per_run: Some(0),
363 }
364 }
365
366 pub(crate) fn is_default(&self) -> bool {
367 self == &Self::default()
368 }
369
370 pub(crate) fn resolve_config(
371 &self,
372 active_provider: &str,
373 active_model: &str,
374 ) -> Result<CompactionConfig, String> {
375 if !self.claude_subscription.allow_compaction
376 && (self.provider.as_deref().is_none_or(|provider| {
377 provider.trim() == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER
378 }) || self
379 .model
380 .as_deref()
381 .is_none_or(|model| model.trim().is_empty()))
382 {
383 return Err("Claude subscription compaction is disabled; select a different provider's Compaction model in Settings → Agent settings".to_string());
384 }
385 let provider = self.provider.as_deref().map(str::trim);
386 let model = self.model.as_deref().map(str::trim);
387 match (provider, model) {
388 (None, None) => Ok(CompactionConfig {
389 provider: non_blank_active(active_provider, "provider")?.to_string(),
390 model: non_blank_active(active_model, "model")?.to_string(),
391 }),
392 (Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
393 Ok(CompactionConfig {
394 provider: provider.to_string(),
395 model: model.to_string(),
396 })
397 }
398 _ => Err("compaction.provider and compaction.model must either both be configured and non-blank, or both be omitted to inherit the active provider/model".to_string()),
399 }
400 }
401}
402
403fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
404 let trimmed = value.trim();
405 if trimmed.is_empty() {
406 Err(format!(
407 "active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
408 ))
409 } else {
410 Ok(trimmed)
411 }
412}
413
414#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
415pub struct SessionTitleSettings {
416 #[serde(default)]
417 pub enabled: bool,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub provider: Option<String>,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub model: Option<String>,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub(crate) struct SessionTitleConfig {
426 pub(crate) provider: String,
427 pub(crate) model: String,
428}
429
430impl SessionTitleSettings {
431 pub(crate) fn is_default(&self) -> bool {
432 self == &Self::default()
433 }
434
435 pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
436 if !self.enabled {
437 return Ok(None);
438 }
439 let provider = self
440 .provider
441 .as_deref()
442 .map(str::trim)
443 .filter(|value| !value.is_empty())
444 .ok_or_else(|| "session_titles.enabled is true but session_titles.provider is missing or blank; set an explicit title provider or disable session_titles".to_string())?;
445 let model = self
446 .model
447 .as_deref()
448 .map(str::trim)
449 .filter(|value| !value.is_empty())
450 .ok_or_else(|| "session_titles.enabled is true but session_titles.model is missing or blank; set an explicit title model or disable session_titles".to_string())?;
451 Ok(Some(SessionTitleConfig {
452 provider: provider.to_string(),
453 model: model.to_string(),
454 }))
455 }
456}
457
458#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
460pub struct SummarizerSettings {
461 #[serde(default)]
463 pub auto_start: bool,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub provider: Option<String>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub model: Option<String>,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub reasoning: Option<crate::thinking::ThinkingLevel>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub prompt: Option<String>,
473}
474
475impl SummarizerSettings {
476 pub(crate) fn is_default(&self) -> bool {
477 self == &Self::default()
478 }
479
480 pub(crate) fn validate(&self) -> anyhow::Result<()> {
481 for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
482 if let Some(value) = value {
483 super::tools::validate_model_identifier(
484 &format!("agent.summarizer.{field}"),
485 value,
486 )?;
487 }
488 }
489 if self
490 .prompt
491 .as_deref()
492 .is_some_and(|prompt| prompt.trim().is_empty())
493 {
494 anyhow::bail!(
495 "agent.summarizer.prompt must not be blank; omit it to use the built-in prompt"
496 );
497 }
498 Ok(())
499 }
500}