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