Skip to main content

wisp/session/
session_config_view.rs

1use acp_utils::config_meta::{ConfigOptionMeta, SelectOptionMeta};
2use acp_utils::config_option_id::ConfigOptionId;
3use agent_client_protocol::schema::v1::{self as acp, SessionConfigOptionCategory};
4use utils::ReasoningEffort;
5
6/// Client-owned configuration state projected from an ACP session schema.
7///
8/// ACP values are copied at the protocol boundary so optimistic edits and
9/// reconciliation never mutate a protocol object borrowed by the UI.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct LocalConfigOption {
12    pub id: String,
13    pub name: String,
14    pub(crate) category: Option<SessionConfigOptionCategory>,
15    pub(crate) meta: Option<acp::Meta>,
16    pub(crate) kind: LocalConfigKind,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum LocalConfigKind {
21    Select { current_value: String, values: Vec<LocalConfigValue>, multi_select: bool },
22    Boolean { current_value: bool },
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct LocalConfigValue {
27    pub value: String,
28    pub name: String,
29    pub group: Option<String>,
30    pub description: Option<String>,
31    pub is_disabled: bool,
32    pub meta: SelectOptionMeta,
33    pub raw_meta: Option<acp::Meta>,
34}
35
36impl LocalConfigOption {
37    pub fn from_acp(option: acp::SessionConfigOption) -> Self {
38        let kind = match option.kind {
39            acp::SessionConfigKind::Select(select) => LocalConfigKind::Select {
40                current_value: select.current_value.0.to_string(),
41                values: match select.options {
42                    acp::SessionConfigSelectOptions::Ungrouped(options) => {
43                        options.into_iter().map(|option| (None, option)).collect()
44                    }
45                    acp::SessionConfigSelectOptions::Grouped(groups) => groups
46                        .into_iter()
47                        .flat_map(|group| {
48                            let name = group.name;
49                            group.options.into_iter().map(move |option| (Some(name.clone()), option))
50                        })
51                        .collect(),
52                    _ => Vec::new(),
53                }
54                .into_iter()
55                .map(|(group, value)| LocalConfigValue {
56                    value: value.value.0.to_string(),
57                    name: value.name,
58                    group,
59                    is_disabled: value.description.as_deref().is_some_and(|text| text.starts_with("Unavailable:")),
60                    description: value.description,
61                    meta: SelectOptionMeta::from_meta(value.meta.as_ref()),
62                    raw_meta: value.meta,
63                })
64                .collect(),
65                multi_select: ConfigOptionMeta::from_meta(option.meta.as_ref()).multi_select,
66            },
67            acp::SessionConfigKind::Boolean(boolean) => {
68                LocalConfigKind::Boolean { current_value: boolean.current_value }
69            }
70            _ => LocalConfigKind::Boolean { current_value: false },
71        };
72        Self { id: option.id.0.to_string(), name: option.name, category: option.category, meta: option.meta, kind }
73    }
74
75    pub(crate) fn select(&self) -> Option<LocalConfigSelect<'_>> {
76        match &self.kind {
77            LocalConfigKind::Select { current_value, values, multi_select: _ } => {
78                Some(LocalConfigSelect { current_value, values })
79            }
80            LocalConfigKind::Boolean { .. } => None,
81        }
82    }
83
84    /// The selected value of a select option, `None` for other kinds.
85    pub fn current_value(&self) -> Option<&str> {
86        match &self.kind {
87            LocalConfigKind::Select { current_value, .. } => Some(current_value),
88            LocalConfigKind::Boolean { .. } => None,
89        }
90    }
91}
92
93pub struct LocalConfigSelect<'a> {
94    pub current_value: &'a str,
95    pub values: &'a [LocalConfigValue],
96}
97
98/// A pure view over client-owned configuration state.
99pub struct LocalConfigView<'a> {
100    options: &'a [LocalConfigOption],
101}
102
103impl<'a> LocalConfigView<'a> {
104    pub fn new(options: &'a [LocalConfigOption]) -> Self {
105        Self { options }
106    }
107
108    pub fn select(&self, id: ConfigOptionId) -> Option<LocalConfigSelect<'a>> {
109        self.options.iter().find(|option| option.id == id.as_str()).and_then(LocalConfigOption::select)
110    }
111
112    pub fn flattened_options(&self, id: ConfigOptionId) -> Vec<&'a LocalConfigValue> {
113        self.select(id).map_or_else(Vec::new, |select| select.values.iter().collect())
114    }
115
116    pub fn current_values(&self, id: ConfigOptionId) -> Vec<&'a str> {
117        self.select(id)
118            .map(|select| select.current_value.split(',').map(str::trim).filter(|value| !value.is_empty()).collect())
119            .unwrap_or_default()
120    }
121
122    pub fn current_display_name(&self, id: ConfigOptionId) -> Option<String> {
123        let values = self.current_values(id);
124        let options = self.flattened_options(id);
125        let names: Vec<_> = values
126            .iter()
127            .filter_map(|value| options.iter().find(|option| option.value == *value).map(|option| option.name.as_str()))
128            .collect();
129        (!names.is_empty()).then(|| names.join(" + "))
130    }
131
132    pub fn next_mode(&self) -> Option<(&'a str, &'a str)> {
133        let option = self
134            .options
135            .iter()
136            .find(|option| option.category == Some(SessionConfigOptionCategory::Mode) && option.select().is_some())?;
137        let select = option.select()?;
138        let current = select.values.iter().position(|value| value.value == select.current_value).unwrap_or(0);
139        let next = select.values.get((current + 1) % select.values.len().max(1))?;
140        Some((option.id.as_str(), next.value.as_str()))
141    }
142
143    pub fn reasoning_levels(&self) -> Vec<ReasoningEffort> {
144        self.flattened_options(ConfigOptionId::ReasoningEffort)
145            .into_iter()
146            .filter_map(|option| option.value.parse().ok())
147            .collect()
148    }
149
150    pub fn reasoning_effort(&self) -> Option<ReasoningEffort> {
151        ReasoningEffort::parse(self.select(ConfigOptionId::ReasoningEffort)?.current_value).unwrap_or(None)
152    }
153
154    pub fn selected_model_metadata(&self) -> Vec<SelectOptionMeta> {
155        let values = self.current_values(ConfigOptionId::Model);
156        let options = self.flattened_options(ConfigOptionId::Model);
157        values
158            .iter()
159            .filter_map(|value| options.iter().find(|option| option.value == *value).map(|option| option.meta.clone()))
160            .collect()
161    }
162}