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 crate::workspace_status::WorkspaceStatus;
4use acp_utils::config_option_id::ConfigOptionId;
5use agent_client_protocol::schema::{
6    self as acp, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOptions,
7};
8use tui::{Color, FitOptions, Frame, Line, ViewContext};
9use utils::ReasoningEffort;
10
11pub use crate::components::context_bar::ContextUsageDisplay;
12
13#[doc = include_str!("../docs/status_line.md")]
14pub struct StatusLine<'a> {
15    pub workspace_status: &'a WorkspaceStatus,
16    pub agent_name: &'a str,
17    pub config_options: &'a [SessionConfigOption],
18    pub context_usage: Option<ContextUsageDisplay>,
19    pub waiting_for_response: bool,
20    pub unhealthy_server_count: usize,
21    pub content_padding: usize,
22    pub exit_confirmation_active: bool,
23}
24
25impl StatusLine<'_> {
26    pub fn render(&self, context: &ViewContext) -> Frame {
27        let left = render_left(self.workspace_status, context, self.content_padding);
28        let right = render_right(self, context);
29        let width = context.size.width as usize;
30
31        let lines = if left.display_width() + right.display_width() <= width {
32            vec![join_aligned(left, &right, width)]
33        } else {
34            vec![left, align_left(&right, self.content_padding)]
35        };
36
37        Frame::new(lines).fit(context.size.width, FitOptions::truncate())
38    }
39}
40
41fn render_left(status: &WorkspaceStatus, context: &ViewContext, content_padding: usize) -> Line {
42    let mut line = Line::default();
43    line.push_text(" ".repeat(content_padding));
44    line.push_styled(status.display_dir.as_str(), context.theme.secondary());
45    if let Some(ref git_ref) = status.git_ref {
46        line.push_styled(" · ", context.theme.text_secondary());
47        line.push_styled(git_ref.as_str(), context.theme.success());
48    }
49    line
50}
51
52fn render_right(status: &StatusLine<'_>, context: &ViewContext) -> Line {
53    let sep = context.theme.text_secondary();
54    let mode_text = extract_mode_display(status.config_options);
55    let model_summary = extract_model_display(status.config_options);
56    let reasoning_effort = extract_reasoning_effort(status.config_options);
57
58    let mut line = Line::default();
59
60    if status.exit_confirmation_active {
61        line.push_styled("Ctrl-C again to exit", context.theme.warning());
62    } else {
63        line.push_styled(status.agent_name, context.theme.info());
64
65        if let Some(ref mode) = mode_text {
66            push_separator(&mut line, sep);
67            line.push_styled(mode.clone(), context.theme.secondary());
68        }
69
70        if let Some(ref model) = model_summary {
71            push_separator(&mut line, sep);
72            line.push_styled(model.clone(), context.theme.success());
73        }
74    }
75
76    let reasoning_levels = extract_reasoning_levels(status.config_options);
77    if model_summary.is_some() && !reasoning_levels.is_empty() {
78        push_separator(&mut line, sep);
79        line.push_styled(
80            reasoning_bar(reasoning_effort, reasoning_levels.len()),
81            reasoning_color(reasoning_effort, reasoning_levels.len(), &context.theme),
82        );
83    }
84
85    if let Some(usage) = status.context_usage {
86        push_separator(&mut line, sep);
87        line.push_styled(context_bar(usage), context_color(usage, &context.theme));
88    }
89
90    if !status.waiting_for_response && status.unhealthy_server_count > 0 {
91        let count = status.unhealthy_server_count;
92        let msg = if count == 1 { "1 server needs auth".to_string() } else { format!("{count} servers unhealthy") };
93        push_separator(&mut line, sep);
94        line.push_styled(msg, context.theme.warning());
95    }
96
97    line
98}
99
100fn join_aligned(mut left: Line, right: &Line, width: usize) -> Line {
101    let padding = width.saturating_sub(left.display_width() + right.display_width());
102    left.push_text(" ".repeat(padding));
103    left.append_line(right);
104    left
105}
106
107fn align_left(right: &Line, content_padding: usize) -> Line {
108    let mut line = Line::default();
109    line.push_text(" ".repeat(content_padding));
110    line.append_line(right);
111    line
112}
113
114fn push_separator(line: &mut Line, color: Color) {
115    if !line.is_empty() {
116        line.push_styled(" · ", color);
117    }
118}
119
120/// Extract the parsed reasoning levels from config options (excludes "none").
121pub(crate) fn extract_reasoning_levels(config_options: &[SessionConfigOption]) -> Vec<ReasoningEffort> {
122    let Some(option) = config_options.iter().find(|o| o.id.0.as_ref() == ConfigOptionId::ReasoningEffort.as_str())
123    else {
124        return Vec::new();
125    };
126    let SessionConfigKind::Select(ref select) = option.kind else {
127        return Vec::new();
128    };
129    let SessionConfigSelectOptions::Ungrouped(ref options) = select.options else {
130        return Vec::new();
131    };
132    options.iter().filter_map(|o| o.value.0.as_ref().parse().ok()).collect()
133}
134
135pub(crate) fn is_cycleable_mode_option(option: &SessionConfigOption) -> bool {
136    matches!(option.kind, SessionConfigKind::Select(_)) && option.category == Some(SessionConfigOptionCategory::Mode)
137}
138
139pub(crate) fn option_display_name(
140    options: &SessionConfigSelectOptions,
141    current_value: &acp::SessionConfigValueId,
142) -> Option<String> {
143    match options {
144        SessionConfigSelectOptions::Ungrouped(options) => {
145            options.iter().find(|option| &option.value == current_value).map(|option| option.name.clone())
146        }
147        SessionConfigSelectOptions::Grouped(groups) => groups
148            .iter()
149            .flat_map(|group| group.options.iter())
150            .find(|option| &option.value == current_value)
151            .map(|option| option.name.clone()),
152        _ => None,
153    }
154}
155
156pub(crate) fn extract_select_display(config_options: &[SessionConfigOption], id: ConfigOptionId) -> Option<String> {
157    let option = config_options.iter().find(|option| option.id.0.as_ref() == id.as_str())?;
158
159    let SessionConfigKind::Select(ref select) = option.kind else {
160        return None;
161    };
162
163    option_display_name(&select.options, &select.current_value)
164}
165
166pub(crate) fn extract_mode_display(config_options: &[SessionConfigOption]) -> Option<String> {
167    extract_select_display(config_options, ConfigOptionId::Mode)
168}
169
170pub(crate) fn extract_model_display(config_options: &[SessionConfigOption]) -> Option<String> {
171    let option = config_options.iter().find(|option| option.id.0.as_ref() == ConfigOptionId::Model.as_str())?;
172
173    let SessionConfigKind::Select(ref select) = option.kind else {
174        return None;
175    };
176
177    let options = match &select.options {
178        SessionConfigSelectOptions::Ungrouped(options) => options,
179        SessionConfigSelectOptions::Grouped(_) => {
180            return extract_select_display(config_options, ConfigOptionId::Model);
181        }
182        _ => return None,
183    };
184
185    let current = select.current_value.0.as_ref();
186    if current.contains(',') {
187        let names: Vec<&str> = current
188            .split(',')
189            .filter_map(|part| {
190                let trimmed = part.trim();
191                options.iter().find(|option| option.value.0.as_ref() == trimmed).map(|option| option.name.as_str())
192            })
193            .collect();
194        if names.is_empty() { None } else { Some(names.join(" + ")) }
195    } else {
196        extract_select_display(config_options, ConfigOptionId::Model)
197    }
198}
199
200pub(crate) fn extract_reasoning_effort(config_options: &[SessionConfigOption]) -> Option<ReasoningEffort> {
201    let option =
202        config_options.iter().find(|option| option.id.0.as_ref() == ConfigOptionId::ReasoningEffort.as_str())?;
203
204    let SessionConfigKind::Select(ref select) = option.kind else {
205        return None;
206    };
207
208    ReasoningEffort::parse(&select.current_value.0).unwrap_or(None)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::settings::DEFAULT_CONTENT_PADDING;
215    use crate::workspace_status::WorkspaceStatus;
216
217    fn test_workspace_status() -> WorkspaceStatus {
218        WorkspaceStatus::new("~/code/aether-2", Some("main".to_string()))
219    }
220
221    fn model_option() -> SessionConfigOption {
222        acp::SessionConfigOption::select(
223            "model",
224            "Model",
225            "claude-sonnet",
226            vec![acp::SessionConfigSelectOption::new("claude-sonnet", "Claude Sonnet")],
227        )
228    }
229
230    fn reasoning_option() -> SessionConfigOption {
231        acp::SessionConfigOption::select(
232            "reasoning_effort",
233            "Reasoning",
234            "medium",
235            vec![
236                acp::SessionConfigSelectOption::new("low", "Low"),
237                acp::SessionConfigSelectOption::new("medium", "Medium"),
238                acp::SessionConfigSelectOption::new("high", "High"),
239            ],
240        )
241    }
242
243    fn status_line() -> StatusLine<'static> {
244        static WORKSPACE_STATUS: std::sync::LazyLock<WorkspaceStatus> = std::sync::LazyLock::new(test_workspace_status);
245
246        StatusLine {
247            workspace_status: &WORKSPACE_STATUS,
248            agent_name: "test-agent",
249            config_options: &[],
250            context_usage: None,
251            waiting_for_response: false,
252            unhealthy_server_count: 0,
253            content_padding: DEFAULT_CONTENT_PADDING,
254            exit_confirmation_active: false,
255        }
256    }
257
258    #[test]
259    fn reasoning_bar_hidden_without_reasoning_option() {
260        let options = vec![model_option()];
261        let status = StatusLine { config_options: &options, ..status_line() };
262
263        let context = ViewContext::new((120, 40));
264        let frame = status.render(&context);
265        let text = frame.lines()[0].plain_text();
266        assert!(
267            !text.contains("reasoning"),
268            "reasoning bar should be hidden when no reasoning_effort option exists, got: {text}"
269        );
270    }
271
272    #[test]
273    fn reasoning_bar_shown_with_reasoning_option() {
274        let options = vec![model_option(), reasoning_option()];
275        let status = StatusLine { config_options: &options, ..status_line() };
276
277        let context = ViewContext::new((120, 40));
278        let frame = status.render(&context);
279        let text = frame.lines()[0].plain_text();
280        assert!(text.contains("medium"), "reasoning bar should use current reasoning effort as its label, got: {text}");
281        assert!(!text.contains("reasoning"), "reasoning bar should not use a generic reasoning label, got: {text}");
282    }
283
284    #[test]
285    fn wraps_right_side_onto_second_line_when_too_narrow() {
286        let options = vec![model_option(), reasoning_option()];
287        let status = StatusLine { config_options: &options, ..status_line() };
288
289        let context = ViewContext::new((60, 40));
290        let frame = status.render(&context);
291        let left = frame.lines()[0].plain_text();
292        let right = frame.lines()[1].plain_text();
293
294        assert_eq!(frame.lines().len(), 2);
295        assert!(left.contains("aether-2"));
296        assert!(right.contains("test-agent"));
297        assert!(right.contains("Claude Sonnet"));
298        assert_eq!(right.find("test-agent"), Some(DEFAULT_CONTENT_PADDING));
299    }
300
301    #[test]
302    fn stays_on_one_line_when_it_fits() {
303        let options = vec![model_option(), reasoning_option()];
304        let status = StatusLine { config_options: &options, ..status_line() };
305
306        let context = ViewContext::new((120, 40));
307        let frame = status.render(&context);
308        assert_eq!(frame.lines().len(), 1, "wide status line should stay on a single row");
309    }
310
311    #[test]
312    fn extract_reasoning_levels_empty_without_option() {
313        let options = vec![model_option()];
314        assert!(extract_reasoning_levels(&options).is_empty());
315    }
316
317    #[test]
318    fn extract_reasoning_levels_nonempty_with_option() {
319        let options = vec![model_option(), reasoning_option()];
320        assert!(!extract_reasoning_levels(&options).is_empty());
321    }
322}