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