Skip to main content

wisp/components/
status_line.rs

1use crate::components::context_bar::{context_bar, context_color};
2use crate::components::reasoning_bar::{reasoning_bar, reasoning_color};
3use acp_utils::config_option_id::ConfigOptionId;
4use agent_client_protocol::{
5    self as acp, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory,
6    SessionConfigSelectOptions,
7};
8use tui::{Color, Line, ViewContext, display_width_text};
9use utils::ReasoningEffort;
10
11pub struct StatusLine<'a> {
12    pub agent_name: &'a str,
13    pub config_options: &'a [SessionConfigOption],
14    pub context_pct_left: Option<u8>,
15    pub waiting_for_response: bool,
16    pub unhealthy_server_count: usize,
17}
18
19impl StatusLine<'_> {
20    #[allow(clippy::similar_names)]
21    pub fn render(&self, context: &ViewContext) -> Vec<Line> {
22        let mode_display = extract_mode_display(self.config_options);
23        let model_display = extract_model_display(self.config_options);
24        let reasoning_effort = extract_reasoning_effort(self.config_options);
25
26        let mut left_line = Line::default();
27        let sep = context.theme.text_secondary();
28
29        left_line.push_text("  ");
30        left_line.push_styled(self.agent_name, context.theme.info());
31
32        if let Some(ref mode) = mode_display {
33            left_line.push_styled(" · ", sep);
34            left_line.push_styled(mode.as_str(), context.theme.secondary());
35        }
36
37        if let Some(ref model) = model_display {
38            left_line.push_styled(" · ", sep);
39            left_line.push_styled(model.as_str(), context.theme.success());
40        }
41
42        let mut right_parts: Vec<(String, Color)> = Vec::new();
43
44        let reasoning_levels = extract_reasoning_levels(self.config_options);
45        if model_display.is_some() && !reasoning_levels.is_empty() {
46            right_parts.push((
47                reasoning_bar(reasoning_effort, reasoning_levels.len()),
48                reasoning_color(reasoning_effort, reasoning_levels.len(), &context.theme),
49            ));
50        }
51
52        if model_display.is_some() || self.context_pct_left.is_some() {
53            let pct = self.context_pct_left.unwrap_or(100);
54            if !right_parts.is_empty() {
55                right_parts.push((" · ".to_string(), sep));
56            }
57            right_parts.push((context_bar(pct), context_color(pct, &context.theme)));
58        }
59
60        if !self.waiting_for_response && self.unhealthy_server_count > 0 {
61            let count = self.unhealthy_server_count;
62            let msg = if count == 1 {
63                "1 server needs auth".to_string()
64            } else {
65                format!("{count} servers unhealthy")
66            };
67            if !right_parts.is_empty() {
68                right_parts.push((" · ".to_string(), sep));
69            }
70            right_parts.push((msg, context.theme.warning()));
71        }
72
73        let width = context.size.width as usize;
74        let right_len: usize = right_parts.iter().map(|(s, _)| display_width_text(s)).sum();
75        let left_len = left_line.display_width();
76
77        let padding = width.saturating_sub(left_len + right_len);
78        left_line.push_text(" ".repeat(padding));
79        for (text, color) in right_parts {
80            left_line.push_styled(text, color);
81        }
82        vec![left_line]
83    }
84}
85
86/// Extract the parsed reasoning levels from config options (excludes "none").
87pub(crate) fn extract_reasoning_levels(
88    config_options: &[SessionConfigOption],
89) -> Vec<ReasoningEffort> {
90    let Some(option) = config_options
91        .iter()
92        .find(|o| o.id.0.as_ref() == ConfigOptionId::ReasoningEffort.as_str())
93    else {
94        return Vec::new();
95    };
96    let SessionConfigKind::Select(ref select) = option.kind else {
97        return Vec::new();
98    };
99    let SessionConfigSelectOptions::Ungrouped(ref options) = select.options else {
100        return Vec::new();
101    };
102    options
103        .iter()
104        .filter_map(|o| o.value.0.as_ref().parse().ok())
105        .collect()
106}
107
108pub(crate) fn is_cycleable_mode_option(option: &SessionConfigOption) -> bool {
109    matches!(option.kind, SessionConfigKind::Select(_))
110        && option.category == Some(SessionConfigOptionCategory::Mode)
111}
112
113pub(crate) fn option_display_name(
114    options: &SessionConfigSelectOptions,
115    current_value: &acp::SessionConfigValueId,
116) -> Option<String> {
117    match options {
118        SessionConfigSelectOptions::Ungrouped(options) => options
119            .iter()
120            .find(|option| &option.value == current_value)
121            .map(|option| option.name.clone()),
122        SessionConfigSelectOptions::Grouped(groups) => groups
123            .iter()
124            .flat_map(|group| group.options.iter())
125            .find(|option| &option.value == current_value)
126            .map(|option| option.name.clone()),
127        _ => None,
128    }
129}
130
131pub(crate) fn extract_select_display(
132    config_options: &[SessionConfigOption],
133    id: ConfigOptionId,
134) -> Option<String> {
135    let option = config_options
136        .iter()
137        .find(|option| option.id.0.as_ref() == id.as_str())?;
138
139    let SessionConfigKind::Select(ref select) = option.kind else {
140        return None;
141    };
142
143    option_display_name(&select.options, &select.current_value)
144}
145
146pub(crate) fn extract_mode_display(config_options: &[SessionConfigOption]) -> Option<String> {
147    extract_select_display(config_options, ConfigOptionId::Mode)
148}
149
150pub(crate) fn extract_model_display(config_options: &[SessionConfigOption]) -> Option<String> {
151    let option = config_options
152        .iter()
153        .find(|option| option.id.0.as_ref() == ConfigOptionId::Model.as_str())?;
154
155    let SessionConfigKind::Select(ref select) = option.kind else {
156        return None;
157    };
158
159    let options = match &select.options {
160        SessionConfigSelectOptions::Ungrouped(options) => options,
161        SessionConfigSelectOptions::Grouped(_) => {
162            return extract_select_display(config_options, ConfigOptionId::Model);
163        }
164        _ => return None,
165    };
166
167    let current = select.current_value.0.as_ref();
168    if current.contains(',') {
169        let names: Vec<&str> = current
170            .split(',')
171            .filter_map(|part| {
172                let trimmed = part.trim();
173                options
174                    .iter()
175                    .find(|option| option.value.0.as_ref() == trimmed)
176                    .map(|option| option.name.as_str())
177            })
178            .collect();
179        if names.is_empty() {
180            None
181        } else {
182            Some(names.join(" + "))
183        }
184    } else {
185        extract_select_display(config_options, ConfigOptionId::Model)
186    }
187}
188
189pub(crate) fn extract_reasoning_effort(
190    config_options: &[SessionConfigOption],
191) -> Option<ReasoningEffort> {
192    let option = config_options
193        .iter()
194        .find(|option| option.id.0.as_ref() == ConfigOptionId::ReasoningEffort.as_str())?;
195
196    let SessionConfigKind::Select(ref select) = option.kind else {
197        return None;
198    };
199
200    ReasoningEffort::parse(&select.current_value.0).unwrap_or(None)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn model_option() -> SessionConfigOption {
208        acp::SessionConfigOption::select(
209            "model",
210            "Model",
211            "claude-sonnet",
212            vec![acp::SessionConfigSelectOption::new(
213                "claude-sonnet",
214                "Claude Sonnet",
215            )],
216        )
217    }
218
219    fn reasoning_option() -> SessionConfigOption {
220        acp::SessionConfigOption::select(
221            "reasoning_effort",
222            "Reasoning",
223            "medium",
224            vec![
225                acp::SessionConfigSelectOption::new("low", "Low"),
226                acp::SessionConfigSelectOption::new("medium", "Medium"),
227                acp::SessionConfigSelectOption::new("high", "High"),
228            ],
229        )
230    }
231
232    #[test]
233    fn reasoning_bar_hidden_without_reasoning_option() {
234        let options = vec![model_option()];
235        let status = StatusLine {
236            agent_name: "test-agent",
237            config_options: &options,
238            context_pct_left: None,
239            waiting_for_response: false,
240            unhealthy_server_count: 0,
241        };
242
243        let context = ViewContext::new((120, 40));
244        let lines = status.render(&context);
245        let text = lines[0].plain_text();
246        assert!(
247            !text.contains("reasoning"),
248            "reasoning bar should be hidden when no reasoning_effort option exists, got: {text}"
249        );
250    }
251
252    #[test]
253    fn reasoning_bar_shown_with_reasoning_option() {
254        let options = vec![model_option(), reasoning_option()];
255        let status = StatusLine {
256            agent_name: "test-agent",
257            config_options: &options,
258            context_pct_left: None,
259            waiting_for_response: false,
260            unhealthy_server_count: 0,
261        };
262
263        let context = ViewContext::new((120, 40));
264        let lines = status.render(&context);
265        let text = lines[0].plain_text();
266        assert!(
267            text.contains("reasoning"),
268            "reasoning bar should be visible when reasoning_effort option exists, got: {text}"
269        );
270    }
271
272    #[test]
273    fn extract_reasoning_levels_empty_without_option() {
274        let options = vec![model_option()];
275        assert!(extract_reasoning_levels(&options).is_empty());
276    }
277
278    #[test]
279    fn extract_reasoning_levels_nonempty_with_option() {
280        let options = vec![model_option(), reasoning_option()];
281        assert!(!extract_reasoning_levels(&options).is_empty());
282    }
283}