1pub mod disk_monitor;
39pub mod git_poller;
40pub mod plugins_upkeep;
41pub mod system_monitor;
42pub mod widgets;
43
44#[cfg(test)]
45mod widget_click_tests;
46
47use std::collections::HashMap;
48use std::time::{Duration, Instant};
49
50use crate::agent_usage::store::UsageStore;
51use crate::agent_usage::update::UpdateRunner;
52use crate::badge::SessionVariables;
53use crate::config::{Config, StatusBarPosition, StatusBarSection, StatusBarWidgetConfig};
54use disk_monitor::DiskMonitor;
55use git_poller::GitBranchPoller;
56use par_term_scripting::plugin_manager::{PluginHost, WarnOnce};
57use system_monitor::SystemMonitor;
58use widgets::{WidgetContext, sorted_widgets_for_section, widget_text};
59
60pub use git_poller::GitStatus;
61
62const AGENT_USAGE_MIN_REFRESH: Duration = Duration::from_secs(30);
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum StatusBarAction {
68 ShowUpdateDialog,
70 OpenAgentUsagePanel,
72 OpenAgentPalette,
75}
76
77pub struct StatusBarUI {
79 system_monitor: SystemMonitor,
81 disk_monitor: DiskMonitor,
83 git_poller: GitBranchPoller,
85 last_mouse_activity: Instant,
87 visible: bool,
89 last_valid_time_format: String,
91 pub update_available_version: Option<String>,
93 pub agent_roster_summary: Option<String>,
98 pub agent_roster_tooltip: Option<String>,
100 pub(crate) usage: UsageStore,
104 usage_update: UpdateRunner,
106 plugins: PluginHost,
109 plugins_last_discovery: Option<Instant>,
112 plugin_settings_warned: WarnOnce,
115 plugin_observer_ids: HashMap<
122 (String, crate::tab::TabId, Option<crate::pane::PaneId>),
123 par_term_emu_core_rust::observer::ObserverId,
124 >,
125}
126
127impl StatusBarUI {
128 pub fn new() -> Self {
130 Self {
131 system_monitor: SystemMonitor::new(),
132 disk_monitor: DiskMonitor::new(),
133 git_poller: GitBranchPoller::new(),
134 last_mouse_activity: Instant::now(),
135 visible: true,
136 last_valid_time_format: "%H:%M:%S".to_string(),
137 update_available_version: None,
138 agent_roster_summary: None,
139 agent_roster_tooltip: None,
140 usage: UsageStore::new(crate::agent_usage::default_records_dir()),
141 usage_update: UpdateRunner::new(),
142 plugins: PluginHost::new(),
143 plugins_last_discovery: None,
144 plugin_settings_warned: WarnOnce::default(),
145 plugin_observer_ids: HashMap::new(),
146 }
147 }
148
149 pub(crate) fn usage_snapshot(&self) -> &crate::agent_usage::store::UsageSnapshot {
151 self.usage.snapshot()
152 }
153
154 pub(crate) fn refresh_usage_now(&mut self) {
157 self.usage.refresh_now();
158 self.usage_update.trigger_if_idle();
159 }
160
161 pub fn signal_shutdown(&self) {
164 self.system_monitor.signal_stop();
165 self.disk_monitor.signal_stop();
166 self.git_poller.signal_stop();
167 }
168
169 pub(crate) fn update_agent_usage(&mut self, config: &Config) {
178 if !config.agent_usage.agent_usage_enabled {
179 return;
180 }
181 self.usage.poll();
182 self.usage.set_hidden(config.agent_usage.hidden_set());
183 self.usage.set_extra_dirs(
186 config
187 .agent_usage
188 .agent_usage_extra_records_dirs
189 .iter()
190 .map(|p| crate::agent_usage::expand_tilde(p))
191 .collect(),
192 );
193 self.usage_update
194 .configure(config.agent_usage.agent_usage_update_command.clone());
195 let interval = Duration::from_secs(
196 config
197 .agent_usage
198 .agent_usage_refresh_interval_sec
199 .max(AGENT_USAGE_MIN_REFRESH.as_secs()),
200 );
201 if self.usage.tick(interval) {
202 self.usage_update.trigger_if_idle();
205 }
206 }
207
208 pub fn height(&self, config: &Config, is_fullscreen: bool) -> f32 {
212 if !config.status_bar.status_bar_enabled || self.should_hide(config, is_fullscreen) {
213 0.0
214 } else {
215 config.status_bar.status_bar_height
216 }
217 }
218
219 pub fn should_hide(&self, config: &Config, is_fullscreen: bool) -> bool {
221 if !config.status_bar.status_bar_enabled {
222 return true;
223 }
224 if config.status_bar.status_bar_auto_hide_fullscreen && is_fullscreen {
225 return true;
226 }
227 if config.status_bar.status_bar_auto_hide_mouse_inactive {
228 let elapsed = self.last_mouse_activity.elapsed().as_secs_f32();
229 if elapsed > config.status_bar.status_bar_mouse_inactive_timeout {
230 return true;
231 }
232 }
233 false
234 }
235
236 pub fn on_mouse_activity(&mut self) {
238 self.last_mouse_activity = Instant::now();
239 self.visible = true;
240 }
241
242 pub fn sync_monitor_state(&self, config: &Config) {
244 if !config.status_bar.status_bar_enabled {
245 if self.system_monitor.is_running() {
246 self.system_monitor.stop();
247 }
248 if self.disk_monitor.is_running() {
249 self.disk_monitor.stop();
250 }
251 if self.git_poller.is_running() {
252 self.git_poller.stop();
253 }
254 return;
255 }
256
257 let needs_monitor = config
259 .status_bar
260 .status_bar_widgets
261 .iter()
262 .any(|w| w.enabled && w.id.needs_system_monitor());
263
264 if needs_monitor && !self.system_monitor.is_running() {
265 self.system_monitor
266 .start(config.status_bar.status_bar_system_poll_interval);
267 } else if !needs_monitor && self.system_monitor.is_running() {
268 self.system_monitor.stop();
269 }
270
271 let needs_disk = config
273 .status_bar
274 .status_bar_widgets
275 .iter()
276 .any(|w| w.enabled && w.id.needs_disk_monitor());
277
278 self.disk_monitor
279 .set_follow_cwd(config.status_bar.status_bar_disk_follow_cwd);
280
281 if needs_disk && !self.disk_monitor.is_running() {
282 self.disk_monitor
283 .start(config.status_bar.status_bar_disk_poll_interval);
284 } else if !needs_disk && self.disk_monitor.is_running() {
285 self.disk_monitor.stop();
286 }
287
288 let needs_git = config
290 .status_bar
291 .status_bar_widgets
292 .iter()
293 .any(|w| w.enabled && w.id == crate::config::WidgetId::GitBranch);
294
295 if needs_git && !self.git_poller.is_running() {
296 self.git_poller
297 .start(config.status_bar.status_bar_git_poll_interval);
298 } else if !needs_git && self.git_poller.is_running() {
299 self.git_poller.stop();
300 }
301 }
302
303 pub fn render(
308 &mut self,
309 ctx: &egui::Context,
310 config: &Config,
311 session_vars: &SessionVariables,
312 is_fullscreen: bool,
313 ) -> (f32, Option<StatusBarAction>) {
314 if !config.status_bar.status_bar_enabled || self.should_hide(config, is_fullscreen) {
315 return (0.0, None);
316 }
317
318 let cwd = if session_vars.path.is_empty() {
320 None
321 } else {
322 Some(session_vars.path.as_str())
323 };
324 self.git_poller.set_cwd(cwd);
325 self.disk_monitor.set_cwd(cwd);
326
327 {
329 use chrono::format::strftime::StrftimeItems;
330 let valid = !config.status_bar.status_bar_time_format.is_empty()
331 && StrftimeItems::new(&config.status_bar.status_bar_time_format)
332 .all(|item| !matches!(item, chrono::format::Item::Error));
333 if valid {
334 self.last_valid_time_format = config.status_bar.status_bar_time_format.clone();
335 }
336 }
337
338 let git_status = self.git_poller.status();
340 let disk_data = self.disk_monitor.data();
341 let widget_ctx = WidgetContext {
342 session_vars: session_vars.clone(),
343 system_data: self.system_monitor.data(),
344 git_branch: git_status.branch,
345 git_ahead: git_status.ahead,
346 git_behind: git_status.behind,
347 git_dirty: git_status.dirty,
348 git_show_status: config.status_bar.status_bar_git_show_status,
349 time_format: self.last_valid_time_format.clone(),
350 update_available_version: self.update_available_version.clone(),
351 disk_free_percent: disk_data.free_percent,
352 disk_free_bytes: disk_data.free_bytes,
353 disk_total_bytes: disk_data.total_bytes,
354 agent_usage_summary: self.usage.summary_line(),
355 agent_roster_summary: self.agent_roster_summary.clone(),
356 agent_roster_tooltip: self.agent_roster_tooltip.clone(),
357 plugin_texts: self.plugins.widget_texts().clone(),
358 };
359
360 let bar_height = config.status_bar.status_bar_height;
361 let [bg_r, bg_g, bg_b] = config.status_bar.status_bar_bg_color;
362 let bg_alpha = (config.status_bar.status_bar_bg_alpha * 255.0) as u8;
363 let bg_color = egui::Color32::from_rgba_unmultiplied(bg_r, bg_g, bg_b, bg_alpha);
364
365 let [fg_r, fg_g, fg_b] = config.status_bar.status_bar_fg_color;
366 let fg_color = egui::Color32::from_rgb(fg_r, fg_g, fg_b);
367 let font_size = config.status_bar.status_bar_font_size;
368 let separator = &config.status_bar.status_bar_separator;
369 let sep_color = fg_color.linear_multiply(0.4);
370
371 let h_margin: f32 = 8.0; let v_margin: f32 = 2.0; let scrollbar_reserved = config.scrollbar.scrollbar_width + 2.0;
377 let viewport = ctx.input(|i| i.viewport_rect());
378 let content_width = (viewport.width() - scrollbar_reserved - h_margin * 2.0).max(0.0);
380 let content_height = (bar_height - v_margin * 2.0).max(0.0);
381
382 let bar_pos = match config.status_bar.status_bar_position {
383 StatusBarPosition::Top => egui::pos2(0.0, 0.0),
384 StatusBarPosition::Bottom => egui::pos2(0.0, viewport.height() - bar_height),
385 };
386
387 let frame = egui::Frame::NONE
388 .fill(bg_color)
389 .inner_margin(egui::Margin::symmetric(h_margin as i8, v_margin as i8));
390
391 let make_rich_text = |text: &str| -> egui::RichText {
392 egui::RichText::new(text)
393 .color(fg_color)
394 .size(font_size)
395 .monospace()
396 };
397
398 let make_sep = |sep: &str| -> egui::RichText {
399 egui::RichText::new(sep)
400 .color(sep_color)
401 .size(font_size)
402 .monospace()
403 };
404
405 let roster_tooltip = self.agent_roster_tooltip.clone();
408 let render_widget = |ui: &mut egui::Ui,
409 w: &StatusBarWidgetConfig,
410 text: &str|
411 -> Option<StatusBarAction> {
412 use crate::config::WidgetId;
413 let (rich, action) = match w.id {
414 WidgetId::UpdateAvailable => (
415 egui::RichText::new(text)
416 .color(egui::Color32::from_rgb(255, 200, 50))
417 .size(font_size)
418 .monospace(),
419 StatusBarAction::ShowUpdateDialog,
420 ),
421 WidgetId::AgentUsage => {
422 (make_rich_text(text), StatusBarAction::OpenAgentUsagePanel)
423 }
424 WidgetId::AgentRoster => (make_rich_text(text), StatusBarAction::OpenAgentPalette),
425 _ => {
426 ui.label(make_rich_text(text));
427 return None;
428 }
429 };
430 let response = ui.add(egui::Label::new(rich).sense(egui::Sense::click()));
431 if w.id == WidgetId::AgentRoster
434 && let Some(tooltip) = roster_tooltip.as_deref()
435 && !tooltip.is_empty()
436 {
437 return response.on_hover_text(tooltip).clicked().then_some(action);
438 }
439 response.clicked().then_some(action)
440 };
441
442 let mut action: Option<StatusBarAction> = None;
443
444 egui::Area::new(egui::Id::new("status_bar"))
445 .fixed_pos(bar_pos)
446 .order(egui::Order::Background)
447 .interactable(true)
448 .show(ctx, |ui| {
449 ui.set_max_width(content_width + h_margin * 2.0);
452 ui.set_max_height(bar_height);
453
454 frame.show(ui, |ui| {
455 ui.set_min_size(egui::vec2(content_width, content_height));
456 ui.set_max_size(egui::vec2(content_width, content_height));
457
458 ui.horizontal_centered(|ui| {
459 ui.set_clip_rect(ui.max_rect());
462
463 let left_widgets = sorted_widgets_for_section(
465 &config.status_bar.status_bar_widgets,
466 StatusBarSection::Left,
467 );
468 let mut first = true;
469 for w in &left_widgets {
470 let text = widget_text(&w.id, &widget_ctx, w.format.as_deref());
471 if text.is_empty() {
472 continue;
473 }
474 if !first {
475 ui.label(make_sep(separator));
476 }
477 first = false;
478 if let Some(a) = render_widget(ui, w, &text) {
479 action = Some(a);
480 }
481 }
482
483 let center_widgets = sorted_widgets_for_section(
485 &config.status_bar.status_bar_widgets,
486 StatusBarSection::Center,
487 );
488 if !center_widgets.is_empty() {
489 ui.with_layout(
490 egui::Layout::centered_and_justified(egui::Direction::LeftToRight),
491 |ui| {
492 let mut first = true;
493 for w in ¢er_widgets {
494 let text =
495 widget_text(&w.id, &widget_ctx, w.format.as_deref());
496 if text.is_empty() {
497 continue;
498 }
499 if !first {
500 ui.label(make_sep(separator));
501 }
502 first = false;
503 if let Some(a) = render_widget(ui, w, &text) {
504 action = Some(a);
505 }
506 }
507 },
508 );
509 }
510
511 let right_widgets = sorted_widgets_for_section(
513 &config.status_bar.status_bar_widgets,
514 StatusBarSection::Right,
515 );
516 if !right_widgets.is_empty() {
517 ui.with_layout(
518 egui::Layout::right_to_left(egui::Align::Center),
519 |ui| {
520 let mut first = true;
521 for w in right_widgets.iter().rev() {
522 let text =
523 widget_text(&w.id, &widget_ctx, w.format.as_deref());
524 if text.is_empty() {
525 continue;
526 }
527 if !first {
528 ui.label(make_sep(separator));
529 }
530 first = false;
531 if let Some(a) = render_widget(ui, w, &text) {
532 action = Some(a);
533 }
534 }
535 },
536 );
537 }
538 });
539 });
540 });
541
542 (bar_height, action)
543 }
544}
545
546impl Default for StatusBarUI {
547 fn default() -> Self {
548 Self::new()
549 }
550}
551
552impl Drop for StatusBarUI {
553 fn drop(&mut self) {
554 self.system_monitor.stop();
555 self.disk_monitor.stop();
556 }
557}