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