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