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::settings::{ResolvedStatusLineSettings, StatusLineSegmentConfig, StatusLineStyle};
4use crate::workspace_status::WorkspaceStatus;
5use acp_utils::config_option_id::ConfigOptionId;
6use agent_client_protocol::schema::{
7    self as acp, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOptions,
8};
9use tui::{Color, FitOptions, Frame, Line, ViewContext, display_width_text};
10use utils::ReasoningEffort;
11
12pub use crate::components::context_bar::ContextUsageDisplay;
13
14#[doc = include_str!("../docs/status_line.md")]
15pub struct StatusLine<'a> {
16    pub workspace_status: &'a WorkspaceStatus,
17    pub agent_name: &'a str,
18    pub config_options: &'a [SessionConfigOption],
19    pub context_usage: Option<ContextUsageDisplay>,
20    pub waiting_for_response: bool,
21    pub unhealthy_server_count: usize,
22    pub content_padding: usize,
23    pub exit_confirmation_active: bool,
24    pub settings: &'a ResolvedStatusLineSettings,
25}
26
27impl StatusLine<'_> {
28    pub fn render(&self, context: &ViewContext) -> Frame {
29        let width = context.size.width as usize;
30        if width == 0 {
31            return Frame::new(vec![Line::default()]);
32        }
33
34        let left = self.render_left_section(context);
35        let left_len = left.display_width();
36        let right = self.render_right_section(context);
37        let right_len = right.display_width();
38
39        let lines = if right.is_empty() {
40            vec![truncate_to_width(left, width)]
41        } else if left_len + 1 + right_len <= width {
42            vec![single_status_line(left, &right, width)]
43        } else {
44            vec![truncate_to_width(left, width), align_left(&right, self.content_padding, width)]
45        };
46
47        Frame::new(lines).fit(context.size.width, FitOptions::truncate())
48    }
49
50    fn render_left_section(&self, context: &ViewContext) -> Line {
51        let mut line = Line::default();
52        line.push_text(" ".repeat(self.content_padding));
53        line.append_line(&self.join_segments(&self.settings.left, context));
54        line
55    }
56
57    fn render_right_section(&self, context: &ViewContext) -> Line {
58        if self.exit_confirmation_active {
59            let mut line = Line::default();
60            line.push_styled("Ctrl-C again to exit", context.theme.warning());
61            return line;
62        }
63        self.join_segments(&self.settings.right, context)
64    }
65
66    fn join_segments(&self, segments: &[StatusLineSegmentConfig], context: &ViewContext) -> Line {
67        let mut line = Line::default();
68        let mut first = true;
69        for segment in segments {
70            let Some(segment_line) = render_segment(segment, self, context) else { continue };
71            if segment_line.is_empty() {
72                continue;
73            }
74            if !first {
75                line.push_styled(&self.settings.separator, context.theme.text_secondary());
76            }
77            line.append_line(&segment_line);
78            first = false;
79        }
80        line
81    }
82}
83
84fn render_segment(segment: &StatusLineSegmentConfig, status: &StatusLine<'_>, context: &ViewContext) -> Option<Line> {
85    match segment {
86        StatusLineSegmentConfig::Cwd { max_width } => {
87            let mut line = Line::default();
88            let dir = truncate_text(&status.workspace_status.display_dir, *max_width);
89            line.push_styled(&dir, context.theme.secondary());
90            Some(line)
91        }
92        StatusLineSegmentConfig::GitRef => {
93            let git_ref = status.workspace_status.git_ref.as_deref()?;
94            let mut line = Line::default();
95            line.push_styled(git_ref, context.theme.success());
96            Some(line)
97        }
98        StatusLineSegmentConfig::Agent => {
99            let mut line = Line::default();
100            line.push_styled(status.agent_name, context.theme.info());
101            Some(line)
102        }
103        StatusLineSegmentConfig::Mode => {
104            let mode_text = extract_mode_display(status.config_options)?;
105            let mut line = Line::default();
106            line.push_styled(&mode_text, context.theme.secondary());
107            Some(line)
108        }
109        StatusLineSegmentConfig::Model { max_width } => {
110            let model_summary = extract_model_display(status.config_options)?;
111            let truncated = truncate_text(&model_summary, *max_width);
112            let mut line = Line::default();
113            line.push_styled(&truncated, context.theme.success());
114            Some(line)
115        }
116        StatusLineSegmentConfig::Reasoning => {
117            let reasoning_levels = extract_reasoning_levels(status.config_options);
118            if reasoning_levels.is_empty() {
119                return None;
120            }
121            let reasoning_effort = extract_reasoning_effort(status.config_options);
122            let mut line = Line::default();
123            line.push_styled(
124                reasoning_bar(reasoning_effort, reasoning_levels.len()),
125                reasoning_color(reasoning_effort, reasoning_levels.len(), &context.theme),
126            );
127            Some(line)
128        }
129        StatusLineSegmentConfig::Context => {
130            let usage = status.context_usage?;
131            let mut line = Line::default();
132            line.push_styled(context_bar(usage), context_color(usage, &context.theme));
133            Some(line)
134        }
135        StatusLineSegmentConfig::ServerHealth => {
136            if status.waiting_for_response || status.unhealthy_server_count == 0 {
137                return None;
138            }
139            let count = status.unhealthy_server_count;
140            let msg = if count == 1 { "1 server needs auth".to_string() } else { format!("{count} servers unhealthy") };
141            let mut line = Line::default();
142            line.push_styled(&msg, context.theme.warning());
143            Some(line)
144        }
145        StatusLineSegmentConfig::Text { value, style } => {
146            let color = style.map_or_else(|| context.theme.secondary(), |s| semantic_color(s, context));
147            let mut line = Line::default();
148            line.push_styled(value, color);
149            Some(line)
150        }
151    }
152}
153
154fn semantic_color(style: StatusLineStyle, context: &ViewContext) -> Color {
155    match style {
156        StatusLineStyle::Primary => context.theme.text_primary(),
157        StatusLineStyle::Secondary => context.theme.secondary(),
158        StatusLineStyle::Muted => context.theme.text_secondary(),
159        StatusLineStyle::Info => context.theme.info(),
160        StatusLineStyle::Success => context.theme.success(),
161        StatusLineStyle::Warning => context.theme.warning(),
162        StatusLineStyle::Error => context.theme.error(),
163    }
164}
165
166fn single_status_line(mut left: Line, right: &Line, width: usize) -> Line {
167    let left_len = left.display_width();
168    let right_len = right.display_width();
169    let padding = width.saturating_sub(left_len + right_len);
170    left.push_text(" ".repeat(padding));
171    left.append_line(right);
172    left
173}
174
175fn align_left(right: &Line, content_padding: usize, width: usize) -> Line {
176    let mut line = Line::default();
177    line.push_text(" ".repeat(content_padding));
178    line.append_line(right);
179    truncate_to_width(line, width)
180}
181
182fn truncate_to_width(line: Line, width: usize) -> Line {
183    let current = line.display_width();
184    if current <= width {
185        return line;
186    }
187    Frame::new(vec![line])
188        .fit(u16::try_from(width).unwrap_or(u16::MAX), FitOptions::truncate())
189        .into_lines()
190        .into_iter()
191        .next()
192        .unwrap_or_default()
193}
194
195fn truncate_text(text: &str, max_width: Option<u16>) -> String {
196    let Some(max_width) = max_width.map(usize::from) else {
197        return text.to_string();
198    };
199    if display_width_text(text) <= max_width {
200        return text.to_string();
201    }
202    let mut result = String::new();
203    let mut current_width = 0;
204    for ch in text.chars() {
205        let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
206        if current_width + char_width > max_width.saturating_sub(1) {
207            result.push('…');
208            break;
209        }
210        result.push(ch);
211        current_width += char_width;
212    }
213    result
214}
215
216/// Extract the parsed reasoning levels from config options (excludes "none").
217pub(crate) fn extract_reasoning_levels(config_options: &[SessionConfigOption]) -> Vec<ReasoningEffort> {
218    let Some(option) = config_options.iter().find(|o| o.id.0.as_ref() == ConfigOptionId::ReasoningEffort.as_str())
219    else {
220        return Vec::new();
221    };
222    let SessionConfigKind::Select(ref select) = option.kind else {
223        return Vec::new();
224    };
225    let SessionConfigSelectOptions::Ungrouped(ref options) = select.options else {
226        return Vec::new();
227    };
228    options.iter().filter_map(|o| o.value.0.as_ref().parse().ok()).collect()
229}
230
231pub(crate) fn is_cycleable_mode_option(option: &SessionConfigOption) -> bool {
232    matches!(option.kind, SessionConfigKind::Select(_)) && option.category == Some(SessionConfigOptionCategory::Mode)
233}
234
235pub(crate) fn option_display_name(
236    options: &SessionConfigSelectOptions,
237    current_value: &acp::SessionConfigValueId,
238) -> Option<String> {
239    match options {
240        SessionConfigSelectOptions::Ungrouped(options) => {
241            options.iter().find(|option| &option.value == current_value).map(|option| option.name.clone())
242        }
243        SessionConfigSelectOptions::Grouped(groups) => groups
244            .iter()
245            .flat_map(|group| group.options.iter())
246            .find(|option| &option.value == current_value)
247            .map(|option| option.name.clone()),
248        _ => None,
249    }
250}
251
252pub(crate) fn extract_select_display(config_options: &[SessionConfigOption], id: ConfigOptionId) -> Option<String> {
253    let option = config_options.iter().find(|option| option.id.0.as_ref() == id.as_str())?;
254
255    let SessionConfigKind::Select(ref select) = option.kind else {
256        return None;
257    };
258
259    option_display_name(&select.options, &select.current_value)
260}
261
262pub(crate) fn extract_mode_display(config_options: &[SessionConfigOption]) -> Option<String> {
263    extract_select_display(config_options, ConfigOptionId::Mode)
264}
265
266pub(crate) fn extract_model_display(config_options: &[SessionConfigOption]) -> Option<String> {
267    let option = config_options.iter().find(|option| option.id.0.as_ref() == ConfigOptionId::Model.as_str())?;
268
269    let SessionConfigKind::Select(ref select) = option.kind else {
270        return None;
271    };
272
273    let options = match &select.options {
274        SessionConfigSelectOptions::Ungrouped(options) => options,
275        SessionConfigSelectOptions::Grouped(_) => {
276            return extract_select_display(config_options, ConfigOptionId::Model);
277        }
278        _ => return None,
279    };
280
281    let current = select.current_value.0.as_ref();
282    if current.contains(',') {
283        let names: Vec<&str> = current
284            .split(',')
285            .filter_map(|part| {
286                let trimmed = part.trim();
287                options.iter().find(|option| option.value.0.as_ref() == trimmed).map(|option| option.name.as_str())
288            })
289            .collect();
290        if names.is_empty() { None } else { Some(names.join(" + ")) }
291    } else {
292        extract_select_display(config_options, ConfigOptionId::Model)
293    }
294}
295
296pub(crate) fn extract_reasoning_effort(config_options: &[SessionConfigOption]) -> Option<ReasoningEffort> {
297    let option =
298        config_options.iter().find(|option| option.id.0.as_ref() == ConfigOptionId::ReasoningEffort.as_str())?;
299
300    let SessionConfigKind::Select(ref select) = option.kind else {
301        return None;
302    };
303
304    ReasoningEffort::parse(&select.current_value.0).unwrap_or(None)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::settings::DEFAULT_CONTENT_PADDING;
311    use crate::settings::StatusLineSettings;
312    use crate::workspace_status::WorkspaceStatus;
313
314    fn default_settings() -> ResolvedStatusLineSettings {
315        StatusLineSettings::resolved_defaults()
316    }
317
318    fn test_workspace_status() -> WorkspaceStatus {
319        WorkspaceStatus::new("~/code/aether-2", Some("main".to_string()))
320    }
321
322    fn status_line<'a>(
323        workspace_status: &'a WorkspaceStatus,
324        settings: &'a ResolvedStatusLineSettings,
325    ) -> StatusLine<'a> {
326        StatusLine {
327            workspace_status,
328            agent_name: "test-agent",
329            config_options: &[],
330            context_usage: None,
331            waiting_for_response: false,
332            unhealthy_server_count: 0,
333            content_padding: DEFAULT_CONTENT_PADDING,
334            exit_confirmation_active: false,
335            settings,
336        }
337    }
338
339    fn model_option() -> SessionConfigOption {
340        acp::SessionConfigOption::select(
341            "model",
342            "Model",
343            "claude-sonnet",
344            vec![acp::SessionConfigSelectOption::new("claude-sonnet", "Claude Sonnet")],
345        )
346    }
347
348    fn reasoning_option() -> SessionConfigOption {
349        acp::SessionConfigOption::select(
350            "reasoning_effort",
351            "Reasoning",
352            "medium",
353            vec![
354                acp::SessionConfigSelectOption::new("low", "Low"),
355                acp::SessionConfigSelectOption::new("medium", "Medium"),
356                acp::SessionConfigSelectOption::new("high", "High"),
357            ],
358        )
359    }
360
361    #[test]
362    fn reasoning_bar_hidden_without_reasoning_option() {
363        let options = vec![model_option()];
364        let workspace_status = test_workspace_status();
365        let settings = default_settings();
366        let status = StatusLine { config_options: &options, ..status_line(&workspace_status, &settings) };
367
368        let context = ViewContext::new((120, 40));
369        let frame = status.render(&context);
370        let text = frame.lines()[0].plain_text();
371        assert!(
372            !text.contains("reasoning"),
373            "reasoning bar should be hidden when no reasoning_effort option exists, got: {text}"
374        );
375    }
376
377    #[test]
378    fn reasoning_bar_shown_with_reasoning_option() {
379        let options = vec![model_option(), reasoning_option()];
380        let workspace_status = test_workspace_status();
381        let settings = default_settings();
382        let status = StatusLine { config_options: &options, ..status_line(&workspace_status, &settings) };
383
384        let context = ViewContext::new((120, 40));
385        let frame = status.render(&context);
386        let text = frame.lines()[0].plain_text();
387        assert!(text.contains("medium"), "reasoning bar should use current reasoning effort as its label, got: {text}");
388        assert!(!text.contains("reasoning"), "reasoning bar should not use a generic reasoning label, got: {text}");
389    }
390
391    #[test]
392    fn wraps_right_side_onto_second_line_when_too_narrow() {
393        let options = vec![model_option(), reasoning_option()];
394        let workspace_status = test_workspace_status();
395        let settings = default_settings();
396        let status = StatusLine { config_options: &options, ..status_line(&workspace_status, &settings) };
397
398        let context = ViewContext::new((60, 40));
399        let frame = status.render(&context);
400        let left = frame.lines()[0].plain_text();
401        let right = frame.lines()[1].plain_text();
402
403        assert_eq!(frame.lines().len(), 2);
404        assert!(left.contains("aether-2"));
405        assert!(right.contains("test-agent"));
406        assert!(right.contains("Claude Sonnet"));
407        assert_eq!(right.find("test-agent"), Some(DEFAULT_CONTENT_PADDING));
408    }
409
410    #[test]
411    fn stays_on_one_line_when_it_fits() {
412        let options = vec![model_option(), reasoning_option()];
413        let workspace_status = test_workspace_status();
414        let settings = default_settings();
415        let status = StatusLine { config_options: &options, ..status_line(&workspace_status, &settings) };
416
417        let context = ViewContext::new((120, 40));
418        let frame = status.render(&context);
419        assert_eq!(frame.lines().len(), 1, "wide status line should stay on a single row");
420    }
421
422    #[test]
423    fn extract_reasoning_levels_empty_without_option() {
424        let options = vec![model_option()];
425        assert!(extract_reasoning_levels(&options).is_empty());
426    }
427
428    #[test]
429    fn extract_reasoning_levels_nonempty_with_option() {
430        let options = vec![model_option(), reasoning_option()];
431        assert!(!extract_reasoning_levels(&options).is_empty());
432    }
433
434    #[test]
435    fn default_status_line_contains_all_segments() {
436        let options = vec![model_option(), reasoning_option()];
437        let workspace_status = test_workspace_status();
438        let settings = default_settings();
439        let status = StatusLine {
440            config_options: &options,
441            context_usage: Some(ContextUsageDisplay::new(144_000, 200_000)),
442            ..status_line(&workspace_status, &settings)
443        };
444
445        let context = ViewContext::new((120, 40));
446        let frame = status.render(&context);
447        let text = frame.lines()[0].plain_text();
448        assert!(text.contains("aether-2"));
449        assert!(text.contains("main"));
450        assert!(text.contains("test-agent"));
451        assert!(text.contains("Claude Sonnet"));
452        assert!(text.contains("medium"));
453    }
454
455    #[test]
456    fn reordered_segments_render_in_configured_order() {
457        let settings = ResolvedStatusLineSettings {
458            separator: " · ".to_string(),
459            left: vec![StatusLineSegmentConfig::Agent],
460            right: vec![StatusLineSegmentConfig::Cwd { max_width: None }],
461        };
462        let workspace_status = test_workspace_status();
463        let status = StatusLine { agent_name: "my-agent", ..status_line(&workspace_status, &settings) };
464
465        let context = ViewContext::new((120, 40));
466        let frame = status.render(&context);
467        let text = frame.lines()[0].plain_text();
468        let agent_pos = text.find("my-agent").expect("should contain agent");
469        let cwd_pos = text.find("aether-2").expect("should contain cwd");
470        assert!(agent_pos < cwd_pos);
471    }
472
473    #[test]
474    fn hidden_segments_do_not_appear() {
475        let settings = ResolvedStatusLineSettings {
476            separator: " · ".to_string(),
477            left: vec![StatusLineSegmentConfig::Cwd { max_width: None }],
478            right: vec![StatusLineSegmentConfig::Agent],
479        };
480        let workspace_status = test_workspace_status();
481        let status = StatusLine {
482            agent_name: "my-agent",
483            config_options: &[model_option()],
484            ..status_line(&workspace_status, &settings)
485        };
486
487        let context = ViewContext::new((120, 40));
488        let frame = status.render(&context);
489        let text = frame.lines()[0].plain_text();
490        assert!(!text.contains("Claude Sonnet"));
491        assert!(!text.contains("main"));
492    }
493
494    #[test]
495    fn missing_segments_no_doubled_separators() {
496        let settings = ResolvedStatusLineSettings {
497            separator: " · ".to_string(),
498            left: vec![StatusLineSegmentConfig::Cwd { max_width: None }],
499            right: vec![
500                StatusLineSegmentConfig::Agent,
501                StatusLineSegmentConfig::Mode,
502                StatusLineSegmentConfig::Model { max_width: None },
503            ],
504        };
505        let workspace_status = WorkspaceStatus::new("~/code/foo", None);
506        let status = StatusLine { config_options: &[model_option()], ..status_line(&workspace_status, &settings) };
507
508        let context = ViewContext::new((120, 40));
509        let frame = status.render(&context);
510        let text = frame.lines()[0].plain_text();
511        assert!(!text.contains("··"));
512    }
513
514    #[test]
515    fn model_max_width_truncates_long_names() {
516        let settings = ResolvedStatusLineSettings {
517            separator: " · ".to_string(),
518            left: vec![StatusLineSegmentConfig::Cwd { max_width: None }],
519            right: vec![StatusLineSegmentConfig::Model { max_width: Some(10) }],
520        };
521        let workspace_status = test_workspace_status();
522        let options = vec![acp::SessionConfigOption::select(
523            "model",
524            "Model",
525            "very-long-model-name",
526            vec![acp::SessionConfigSelectOption::new("very-long-model-name", "Very Long Model Name Indeed")],
527        )];
528        let status =
529            StatusLine { agent_name: "test", config_options: &options, ..status_line(&workspace_status, &settings) };
530
531        let context = ViewContext::new((120, 40));
532        let frame = status.render(&context);
533        let text = frame.lines()[0].plain_text();
534        assert!(!text.contains("Very Long Model Name Indeed"));
535    }
536
537    #[test]
538    fn narrow_width_with_right_section_produces_two_lines() {
539        let settings = ResolvedStatusLineSettings {
540            separator: " · ".to_string(),
541            left: vec![StatusLineSegmentConfig::Cwd { max_width: None }],
542            right: vec![StatusLineSegmentConfig::Agent, StatusLineSegmentConfig::Model { max_width: None }],
543        };
544        let workspace_status = test_workspace_status();
545        let options = vec![model_option()];
546        let status = StatusLine {
547            agent_name: "test-agent-with-a-long-name",
548            config_options: &options,
549            ..status_line(&workspace_status, &settings)
550        };
551
552        let context = ViewContext::new((30, 40));
553        let frame = status.render(&context);
554        assert!(
555            frame.lines().len() == 2,
556            "right section should produce exactly 2 lines when content doesn't fit, got {} lines",
557            frame.lines().len()
558        );
559    }
560
561    #[test]
562    fn narrow_width_without_right_section_produces_one_line() {
563        let settings = ResolvedStatusLineSettings {
564            separator: " · ".to_string(),
565            left: vec![
566                StatusLineSegmentConfig::Cwd { max_width: None },
567                StatusLineSegmentConfig::Agent,
568                StatusLineSegmentConfig::Model { max_width: None },
569            ],
570            right: vec![],
571        };
572        let workspace_status = test_workspace_status();
573        let options = vec![model_option()];
574        let status = StatusLine {
575            agent_name: "test-agent-with-a-long-name",
576            config_options: &options,
577            ..status_line(&workspace_status, &settings)
578        };
579
580        let context = ViewContext::new((30, 40));
581        let frame = status.render(&context);
582        assert_eq!(frame.lines().len(), 1, "omitting right should produce exactly 1 line");
583    }
584
585    #[test]
586    fn exit_confirmation_replaces_right_side() {
587        let settings = default_settings();
588        let workspace_status = test_workspace_status();
589        let options = vec![model_option()];
590        let status = StatusLine {
591            config_options: &options,
592            exit_confirmation_active: true,
593            ..status_line(&workspace_status, &settings)
594        };
595
596        let context = ViewContext::new((120, 40));
597        let frame = status.render(&context);
598        let text = frame.lines()[0].plain_text();
599        assert!(text.contains("Ctrl-C again to exit"), "should show exit warning, got: {text}");
600        assert!(!text.contains("test-agent"), "should not show agent name during exit confirmation, got: {text}");
601    }
602
603    #[test]
604    fn text_segment_with_style() {
605        let settings = ResolvedStatusLineSettings {
606            separator: " · ".to_string(),
607            left: vec![StatusLineSegmentConfig::Text {
608                value: "hello".to_string(),
609                style: Some(StatusLineStyle::Warning),
610            }],
611            right: vec![],
612        };
613        let workspace_status = WorkspaceStatus::new("~/code/foo", None);
614        let status = StatusLine { agent_name: "test", content_padding: 0, ..status_line(&workspace_status, &settings) };
615
616        let context = ViewContext::new((80, 24));
617        let frame = status.render(&context);
618        let text = frame.lines()[0].plain_text();
619        assert!(text.contains("hello"), "should render text segment, got: {text}");
620    }
621
622    #[test]
623    fn zero_width_no_panic() {
624        let settings = default_settings();
625        let workspace_status = test_workspace_status();
626        let status = StatusLine { agent_name: "test", ..status_line(&workspace_status, &settings) };
627
628        let context = ViewContext::new((0, 24));
629        let frame = status.render(&context);
630        assert!(!frame.lines().is_empty(), "should produce at least one line even at width 0");
631    }
632
633    #[test]
634    fn truncate_text_returns_short_input_unchanged() {
635        assert_eq!(truncate_text("short", Some(10)), "short");
636    }
637
638    #[test]
639    fn truncate_text_elides_long_input() {
640        let result = truncate_text("a very long directory path that exceeds the limit", Some(10));
641        assert!(display_width_text(&result) <= 10, "truncated text should fit within max_width");
642        assert!(result.ends_with('…'), "truncated text should end with ellipsis, got: {result}");
643    }
644}