par_term/status_bar/mod.rs
1//! Status bar system for displaying session and system information.
2//!
3//! The status bar is a configurable panel that can display widgets such as
4//! the current time, username, git branch, CPU/memory usage, and more.
5//!
6//! # Widget Architecture
7//!
8//! The current architecture uses a **data-driven, direct-call approach** rather
9//! than a formal registration mechanism:
10//!
11//! - Widget identifiers (`WidgetId`) and their configuration are defined in
12//! `par-term-config/src/status_bar.rs`.
13//! - The `widgets` submodule (`src/status_bar/widgets/`) contains the rendering
14//! logic: `widget_text()` dispatches on `WidgetId` to produce the display string,
15//! and `sorted_widgets_for_section()` filters and orders widgets for each bar section.
16//! - `StatusBarUI::render()` in this file drives the egui layout for left/center/right
17//! sections and calls into the widgets module.
18//! - Background data (system metrics, git status) is polled on dedicated threads
19//! (`SystemMonitor`, `GitBranchPoller`) and surfaced via `WidgetContext`.
20//!
21//! # Adding a New Widget
22//!
23//! 1. Add a variant to `WidgetId` in `par-term-config/src/status_bar.rs`.
24//! 2. Add a `widget_text()` arm in `src/status_bar/widgets/`.
25//! 3. If the widget needs background data, extend `WidgetContext` and add
26//! a poller in this file following the `GitBranchPoller` pattern.
27//! 4. Update `default_widgets()` in `par-term-config` to include the new widget.
28//! 5. Update search keywords in `par-term-settings-ui/src/settings_ui/sidebar.rs`.
29//!
30//! # Future: Formal Widget Registry
31//!
32//! The current dispatch-on-enum approach scales well to ~20 widgets but becomes
33//! harder to extend as the widget set grows. A future improvement could introduce
34//! a `StatusBarWidget` trait and a registry (e.g., `HashMap<WidgetId, Box<dyn StatusBarWidget>>`)
35//! so that third-party or plugin-style widgets can be registered without modifying
36//! the central dispatch function. This is tracked as ARC-009 in AUDIT.md.
37
38pub mod disk_monitor;
39pub mod git_poller;
40pub mod plugins_upkeep;
41pub mod system_monitor;
42pub mod widgets;
43
44use std::collections::HashMap;
45use std::time::{Duration, Instant};
46
47use crate::agent_usage::store::UsageStore;
48use crate::agent_usage::update::UpdateRunner;
49use crate::badge::SessionVariables;
50use crate::config::{Config, StatusBarPosition, StatusBarSection};
51use disk_monitor::DiskMonitor;
52use git_poller::GitBranchPoller;
53use par_term_scripting::plugin_manager::{PluginHost, WarnOnce};
54use system_monitor::SystemMonitor;
55use widgets::{WidgetContext, sorted_widgets_for_section, widget_text};
56
57pub use git_poller::GitStatus;
58
59/// Floor for the configured agent-usage refresh interval.
60const AGENT_USAGE_MIN_REFRESH: Duration = Duration::from_secs(30);
61
62/// Actions that the status bar can request from the window.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum StatusBarAction {
65 /// User clicked the update-available widget.
66 ShowUpdateDialog,
67 /// User clicked the agent-usage widget.
68 OpenAgentUsagePanel,
69 /// User clicked the agent-roster widget; opens the command palette (the
70 /// roster's list surface, per the A2b design — not a separate panel).
71 OpenAgentPalette,
72}
73
74/// Status bar UI state and renderer.
75pub struct StatusBarUI {
76 /// Background system resource monitor.
77 system_monitor: SystemMonitor,
78 /// Background disk free-space monitor.
79 disk_monitor: DiskMonitor,
80 /// Git branch poller.
81 git_poller: GitBranchPoller,
82 /// Timestamp of the last mouse activity (for auto-hide).
83 last_mouse_activity: Instant,
84 /// Whether the status bar is currently visible.
85 visible: bool,
86 /// Last valid time format string (for fallback when user is mid-edit).
87 last_valid_time_format: String,
88 /// Available update version (set by WindowManager when update is detected)
89 pub update_available_version: Option<String>,
90 /// Agent-roster summary line for the status-bar widget (A2b task 2),
91 /// refreshed by the render pipeline from the mux roster cache before the
92 /// egui borrow. `None` (no mux session / empty roster) self-hides the
93 /// widget.
94 pub agent_roster_summary: Option<String>,
95 /// Hover text for the agent-roster widget; `None` hides the tooltip.
96 pub agent_roster_tooltip: Option<String>,
97 /// Agent-usage records store (watched directory + snapshot). Lives here
98 /// with the other background-data pollers; the popup panel reads it via
99 /// [`StatusBarUI::usage_snapshot`].
100 usage: UsageStore,
101 /// Optional agent-usage update command, run on the refresh interval.
102 usage_update: UpdateRunner,
103 /// Window-scoped plugin host: discovery cache, supervised processes,
104 /// and the last `SetWidget` text per plugin (v1 plugins are bar widgets).
105 plugins: PluginHost,
106 /// When the plugins root was last scanned; `None` until the first
107 /// [`update_plugins`](StatusBarUI::update_plugins) call performs it.
108 plugins_last_discovery: Option<Instant>,
109 /// Warn-once gate for per-frame invalid-settings warnings (upkeep runs
110 /// every render frame; a steady fault must not warn per frame).
111 plugin_settings_warned: WarnOnce,
112 /// Terminal-observer registrations for plugin event forwarders, keyed by
113 /// (plugin id, tab id). Reconciled every sweep by
114 /// [`StatusBarUI::pump_plugin_events`]; an entry for a closed tab is
115 /// dropped with the map entry alone — its terminal, and the observer
116 /// registry inside it, is gone with the tab.
117 plugin_observer_ids:
118 HashMap<(String, crate::tab::TabId), par_term_emu_core_rust::observer::ObserverId>,
119}
120
121impl StatusBarUI {
122 /// Create a new status bar UI.
123 pub fn new() -> Self {
124 Self {
125 system_monitor: SystemMonitor::new(),
126 disk_monitor: DiskMonitor::new(),
127 git_poller: GitBranchPoller::new(),
128 last_mouse_activity: Instant::now(),
129 visible: true,
130 last_valid_time_format: "%H:%M:%S".to_string(),
131 update_available_version: None,
132 agent_roster_summary: None,
133 agent_roster_tooltip: None,
134 usage: UsageStore::new(crate::agent_usage::default_records_dir()),
135 usage_update: UpdateRunner::new(),
136 plugins: PluginHost::new(),
137 plugins_last_discovery: None,
138 plugin_settings_warned: WarnOnce::default(),
139 plugin_observer_ids: HashMap::new(),
140 }
141 }
142
143 /// Read access to the agent-usage snapshot for the popup panel.
144 pub(crate) fn usage_snapshot(&self) -> &crate::agent_usage::store::UsageSnapshot {
145 self.usage.snapshot()
146 }
147
148 /// Rescan the usage records directory now (panel `r`), and give the
149 /// optional update command its manual-refresh turn.
150 pub(crate) fn refresh_usage_now(&mut self) {
151 self.usage.refresh_now();
152 self.usage_update.trigger_if_idle();
153 }
154
155 /// Signal all background threads to stop without waiting.
156 /// Call early during shutdown so threads have time to notice before the Drop join.
157 pub fn signal_shutdown(&self) {
158 self.system_monitor.signal_stop();
159 self.disk_monitor.signal_stop();
160 self.git_poller.signal_stop();
161 }
162
163 /// Per-frame agent-usage upkeep: watcher events, config wiring, refresh
164 /// tick, and the optional update command.
165 ///
166 /// Called unconditionally from the render pipeline every frame — NOT from
167 /// [`StatusBarUI::render`], which the caller only reaches while the status
168 /// bar is enabled. The usage panel must stay live with the bar off (it is
169 /// opened by its own action), and a watcher event arriving while the bar
170 /// is hidden must not wait for the bar to come back.
171 pub(crate) fn update_agent_usage(&mut self, config: &Config) {
172 if !config.agent_usage.agent_usage_enabled {
173 return;
174 }
175 self.usage.poll();
176 self.usage.set_hidden(config.agent_usage.hidden_set());
177 self.usage_update
178 .configure(config.agent_usage.agent_usage_update_command.clone());
179 let interval = Duration::from_secs(
180 config
181 .agent_usage
182 .agent_usage_refresh_interval_sec
183 .max(AGENT_USAGE_MIN_REFRESH.as_secs()),
184 );
185 if self.usage.tick(interval) {
186 // The refresh interval elapsed: give the optional update
187 // command its turn alongside the rescan.
188 self.usage_update.trigger_if_idle();
189 }
190 }
191
192 /// Compute the effective height consumed by the status bar.
193 ///
194 /// Returns 0 if the status bar is hidden or disabled.
195 pub fn height(&self, config: &Config, is_fullscreen: bool) -> f32 {
196 if !config.status_bar.status_bar_enabled || self.should_hide(config, is_fullscreen) {
197 0.0
198 } else {
199 config.status_bar.status_bar_height
200 }
201 }
202
203 /// Determine whether the status bar should be hidden right now.
204 pub fn should_hide(&self, config: &Config, is_fullscreen: bool) -> bool {
205 if !config.status_bar.status_bar_enabled {
206 return true;
207 }
208 if config.status_bar.status_bar_auto_hide_fullscreen && is_fullscreen {
209 return true;
210 }
211 if config.status_bar.status_bar_auto_hide_mouse_inactive {
212 let elapsed = self.last_mouse_activity.elapsed().as_secs_f32();
213 if elapsed > config.status_bar.status_bar_mouse_inactive_timeout {
214 return true;
215 }
216 }
217 false
218 }
219
220 /// Record mouse activity (resets auto-hide timer).
221 pub fn on_mouse_activity(&mut self) {
222 self.last_mouse_activity = Instant::now();
223 self.visible = true;
224 }
225
226 /// Start or stop the system monitor and git poller based on enabled widgets.
227 pub fn sync_monitor_state(&self, config: &Config) {
228 if !config.status_bar.status_bar_enabled {
229 if self.system_monitor.is_running() {
230 self.system_monitor.stop();
231 }
232 if self.disk_monitor.is_running() {
233 self.disk_monitor.stop();
234 }
235 if self.git_poller.is_running() {
236 self.git_poller.stop();
237 }
238 return;
239 }
240
241 // System monitor
242 let needs_monitor = config
243 .status_bar
244 .status_bar_widgets
245 .iter()
246 .any(|w| w.enabled && w.id.needs_system_monitor());
247
248 if needs_monitor && !self.system_monitor.is_running() {
249 self.system_monitor
250 .start(config.status_bar.status_bar_system_poll_interval);
251 } else if !needs_monitor && self.system_monitor.is_running() {
252 self.system_monitor.stop();
253 }
254
255 // Disk free-space monitor
256 let needs_disk = config
257 .status_bar
258 .status_bar_widgets
259 .iter()
260 .any(|w| w.enabled && w.id.needs_disk_monitor());
261
262 self.disk_monitor
263 .set_follow_cwd(config.status_bar.status_bar_disk_follow_cwd);
264
265 if needs_disk && !self.disk_monitor.is_running() {
266 self.disk_monitor
267 .start(config.status_bar.status_bar_disk_poll_interval);
268 } else if !needs_disk && self.disk_monitor.is_running() {
269 self.disk_monitor.stop();
270 }
271
272 // Git branch poller
273 let needs_git = config
274 .status_bar
275 .status_bar_widgets
276 .iter()
277 .any(|w| w.enabled && w.id == crate::config::WidgetId::GitBranch);
278
279 if needs_git && !self.git_poller.is_running() {
280 self.git_poller
281 .start(config.status_bar.status_bar_git_poll_interval);
282 } else if !needs_git && self.git_poller.is_running() {
283 self.git_poller.stop();
284 }
285 }
286
287 /// Render the status bar.
288 ///
289 /// Returns the height consumed by the status bar (0 if hidden) and an
290 /// optional action requested by the user (e.g. clicking the update widget).
291 pub fn render(
292 &mut self,
293 ctx: &egui::Context,
294 config: &Config,
295 session_vars: &SessionVariables,
296 is_fullscreen: bool,
297 ) -> (f32, Option<StatusBarAction>) {
298 if !config.status_bar.status_bar_enabled || self.should_hide(config, is_fullscreen) {
299 return (0.0, None);
300 }
301
302 // Update git poller cwd from active tab's path
303 let cwd = if session_vars.path.is_empty() {
304 None
305 } else {
306 Some(session_vars.path.as_str())
307 };
308 self.git_poller.set_cwd(cwd);
309 self.disk_monitor.set_cwd(cwd);
310
311 // Validate time format — update last-known-good on success, fall back on failure
312 {
313 use chrono::format::strftime::StrftimeItems;
314 let valid = !config.status_bar.status_bar_time_format.is_empty()
315 && StrftimeItems::new(&config.status_bar.status_bar_time_format)
316 .all(|item| !matches!(item, chrono::format::Item::Error));
317 if valid {
318 self.last_valid_time_format = config.status_bar.status_bar_time_format.clone();
319 }
320 }
321
322 // Build widget context
323 let git_status = self.git_poller.status();
324 let disk_data = self.disk_monitor.data();
325 let widget_ctx = WidgetContext {
326 session_vars: session_vars.clone(),
327 system_data: self.system_monitor.data(),
328 git_branch: git_status.branch,
329 git_ahead: git_status.ahead,
330 git_behind: git_status.behind,
331 git_dirty: git_status.dirty,
332 git_show_status: config.status_bar.status_bar_git_show_status,
333 time_format: self.last_valid_time_format.clone(),
334 update_available_version: self.update_available_version.clone(),
335 disk_free_percent: disk_data.free_percent,
336 disk_free_bytes: disk_data.free_bytes,
337 disk_total_bytes: disk_data.total_bytes,
338 agent_usage_summary: self.usage.summary_line(),
339 agent_roster_summary: self.agent_roster_summary.clone(),
340 agent_roster_tooltip: self.agent_roster_tooltip.clone(),
341 plugin_texts: self.plugins.widget_texts().clone(),
342 };
343
344 let bar_height = config.status_bar.status_bar_height;
345 let [bg_r, bg_g, bg_b] = config.status_bar.status_bar_bg_color;
346 let bg_alpha = (config.status_bar.status_bar_bg_alpha * 255.0) as u8;
347 let bg_color = egui::Color32::from_rgba_unmultiplied(bg_r, bg_g, bg_b, bg_alpha);
348
349 let [fg_r, fg_g, fg_b] = config.status_bar.status_bar_fg_color;
350 let fg_color = egui::Color32::from_rgb(fg_r, fg_g, fg_b);
351 let font_size = config.status_bar.status_bar_font_size;
352 let separator = &config.status_bar.status_bar_separator;
353 let sep_color = fg_color.linear_multiply(0.4);
354
355 // Use an egui::Area with a fixed size so the status bar stops before
356 // the scrollbar column. TopBottomPanel always spans the full window
357 // width and ignores every attempt to narrow it.
358 let h_margin: f32 = 8.0; // left + right inner margin per side
359 let v_margin: f32 = 2.0; // top + bottom inner margin per side
360 let scrollbar_reserved = config.scrollbar.scrollbar_width + 2.0;
361 let viewport = ctx.input(|i| i.viewport_rect());
362 // Content width is the frame width minus both horizontal margins.
363 let content_width = (viewport.width() - scrollbar_reserved - h_margin * 2.0).max(0.0);
364 let content_height = (bar_height - v_margin * 2.0).max(0.0);
365
366 let bar_pos = match config.status_bar.status_bar_position {
367 StatusBarPosition::Top => egui::pos2(0.0, 0.0),
368 StatusBarPosition::Bottom => egui::pos2(0.0, viewport.height() - bar_height),
369 };
370
371 let frame = egui::Frame::NONE
372 .fill(bg_color)
373 .inner_margin(egui::Margin::symmetric(h_margin as i8, v_margin as i8));
374
375 let make_rich_text = |text: &str| -> egui::RichText {
376 egui::RichText::new(text)
377 .color(fg_color)
378 .size(font_size)
379 .monospace()
380 };
381
382 let make_sep = |sep: &str| -> egui::RichText {
383 egui::RichText::new(sep)
384 .color(sep_color)
385 .size(font_size)
386 .monospace()
387 };
388
389 let mut action: Option<StatusBarAction> = None;
390
391 egui::Area::new(egui::Id::new("status_bar"))
392 .fixed_pos(bar_pos)
393 .order(egui::Order::Background)
394 .interactable(true)
395 .show(ctx, |ui| {
396 // Constrain the outer UI so the frame cannot grow beyond the
397 // intended total width (content + margins).
398 ui.set_max_width(content_width + h_margin * 2.0);
399 ui.set_max_height(bar_height);
400
401 frame.show(ui, |ui| {
402 ui.set_min_size(egui::vec2(content_width, content_height));
403 ui.set_max_size(egui::vec2(content_width, content_height));
404
405 ui.horizontal_centered(|ui| {
406 // Clip widgets to the available content width so
407 // right-to-left layouts cannot expand past the bar edge.
408 ui.set_clip_rect(ui.max_rect());
409
410 // === Left section ===
411 let left_widgets = sorted_widgets_for_section(
412 &config.status_bar.status_bar_widgets,
413 StatusBarSection::Left,
414 );
415 let mut first = true;
416 for w in &left_widgets {
417 let text = widget_text(&w.id, &widget_ctx, w.format.as_deref());
418 if text.is_empty() {
419 continue;
420 }
421 if !first {
422 ui.label(make_sep(separator));
423 }
424 first = false;
425 ui.label(make_rich_text(&text));
426 }
427
428 // === Center section ===
429 let center_widgets = sorted_widgets_for_section(
430 &config.status_bar.status_bar_widgets,
431 StatusBarSection::Center,
432 );
433 if !center_widgets.is_empty() {
434 ui.with_layout(
435 egui::Layout::centered_and_justified(egui::Direction::LeftToRight),
436 |ui| {
437 let mut first = true;
438 for w in ¢er_widgets {
439 let text =
440 widget_text(&w.id, &widget_ctx, w.format.as_deref());
441 if text.is_empty() {
442 continue;
443 }
444 if !first {
445 ui.label(make_sep(separator));
446 }
447 first = false;
448 ui.label(make_rich_text(&text));
449 }
450 },
451 );
452 }
453
454 // === Right section ===
455 let right_widgets = sorted_widgets_for_section(
456 &config.status_bar.status_bar_widgets,
457 StatusBarSection::Right,
458 );
459 if !right_widgets.is_empty() {
460 ui.with_layout(
461 egui::Layout::right_to_left(egui::Align::Center),
462 |ui| {
463 let mut first = true;
464 for w in right_widgets.iter().rev() {
465 let text =
466 widget_text(&w.id, &widget_ctx, w.format.as_deref());
467 if text.is_empty() {
468 continue;
469 }
470 if !first {
471 ui.label(make_sep(separator));
472 }
473 first = false;
474 if w.id == crate::config::WidgetId::UpdateAvailable {
475 let update_text = egui::RichText::new(&text)
476 .color(egui::Color32::from_rgb(255, 200, 50))
477 .size(font_size)
478 .monospace();
479 if ui
480 .add(
481 egui::Label::new(update_text)
482 .sense(egui::Sense::click()),
483 )
484 .clicked()
485 {
486 action = Some(StatusBarAction::ShowUpdateDialog);
487 }
488 } else if w.id == crate::config::WidgetId::AgentUsage {
489 // Same clickable treatment as the update widget;
490 // v1 is right-section-only (documented limitation).
491 let usage_text = egui::RichText::new(&text)
492 .color(fg_color)
493 .size(font_size)
494 .monospace();
495 if ui
496 .add(
497 egui::Label::new(usage_text)
498 .sense(egui::Sense::click()),
499 )
500 .clicked()
501 {
502 action = Some(StatusBarAction::OpenAgentUsagePanel);
503 }
504 } else if w.id == crate::config::WidgetId::AgentRoster {
505 // Same clickable treatment as the
506 // usage widget; hover carries the
507 // per-agent provenance detail the
508 // bar text cannot fit.
509 let roster_text = egui::RichText::new(&text)
510 .color(fg_color)
511 .size(font_size)
512 .monospace();
513 let response = ui.add(
514 egui::Label::new(roster_text)
515 .sense(egui::Sense::click()),
516 );
517 let response =
518 match self.agent_roster_tooltip.as_deref() {
519 Some(tooltip) if !tooltip.is_empty() => {
520 response.on_hover_text(tooltip)
521 }
522 _ => response,
523 };
524 if response.clicked() {
525 action = Some(StatusBarAction::OpenAgentPalette);
526 }
527 } else {
528 ui.label(make_rich_text(&text));
529 }
530 }
531 },
532 );
533 }
534 });
535 });
536 });
537
538 (bar_height, action)
539 }
540}
541
542impl Default for StatusBarUI {
543 fn default() -> Self {
544 Self::new()
545 }
546}
547
548impl Drop for StatusBarUI {
549 fn drop(&mut self) {
550 self.system_monitor.stop();
551 self.disk_monitor.stop();
552 }
553}