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
216pub(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}