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