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