Skip to main content

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
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
62/// Floor for the configured agent-usage refresh interval.
63const AGENT_USAGE_MIN_REFRESH: Duration = Duration::from_secs(30);
64
65/// Actions that the status bar can request from the window.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum StatusBarAction {
68    /// User clicked the update-available widget.
69    ShowUpdateDialog,
70    /// User clicked the agent-usage widget.
71    OpenAgentUsagePanel,
72    /// User clicked the agent-roster widget; opens the command palette (the
73    /// roster's list surface, per the A2b design — not a separate panel).
74    OpenAgentPalette,
75}
76
77/// Status bar UI state and renderer.
78pub struct StatusBarUI {
79    /// Background system resource monitor.
80    system_monitor: SystemMonitor,
81    /// Background disk free-space monitor.
82    disk_monitor: DiskMonitor,
83    /// Git branch poller.
84    git_poller: GitBranchPoller,
85    /// Timestamp of the last mouse activity (for auto-hide).
86    last_mouse_activity: Instant,
87    /// Whether the status bar is currently visible.
88    visible: bool,
89    /// Last valid time format string (for fallback when user is mid-edit).
90    last_valid_time_format: String,
91    /// Available update version (set by WindowManager when update is detected)
92    pub update_available_version: Option<String>,
93    /// Agent-roster summary line for the status-bar widget (A2b task 2),
94    /// refreshed by the render pipeline from the mux roster cache before the
95    /// egui borrow. `None` (no mux session / empty roster) self-hides the
96    /// widget.
97    pub agent_roster_summary: Option<String>,
98    /// Hover text for the agent-roster widget; `None` hides the tooltip.
99    pub agent_roster_tooltip: Option<String>,
100    /// Agent-usage records store (watched directory + snapshot). Lives here
101    /// with the other background-data pollers; the popup panel reads it via
102    /// [`StatusBarUI::usage_snapshot`].
103    pub(crate) usage: UsageStore,
104    /// Optional agent-usage update command, run on the refresh interval.
105    usage_update: UpdateRunner,
106    /// Window-scoped plugin host: discovery cache, supervised processes,
107    /// and the last `SetWidget` text per plugin (v1 plugins are bar widgets).
108    plugins: PluginHost,
109    /// When the plugins root was last scanned; `None` until the first
110    /// [`update_plugins`](StatusBarUI::update_plugins) call performs it.
111    plugins_last_discovery: Option<Instant>,
112    /// Warn-once gate for per-frame invalid-settings warnings (upkeep runs
113    /// every render frame; a steady fault must not warn per frame).
114    plugin_settings_warned: WarnOnce,
115    /// Terminal-observer registrations for plugin event forwarders, keyed by
116    /// (plugin id, tab id, terminal slot): `None` names the tab terminal,
117    /// `Some(pane id)` a pane terminal of a mux tab. Reconciled every sweep
118    /// by [`StatusBarUI::pump_plugin_events`]; an entry for a closed tab or
119    /// pane is dropped with the map entry alone — its terminal, and the
120    /// observer registry inside it, is gone with it.
121    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    /// Create a new status bar UI.
129    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    /// Read access to the agent-usage snapshot for the popup panel.
150    pub(crate) fn usage_snapshot(&self) -> &crate::agent_usage::store::UsageSnapshot {
151        self.usage.snapshot()
152    }
153
154    /// Rescan the usage records directory now (panel `r`), and give the
155    /// optional update command its manual-refresh turn.
156    pub(crate) fn refresh_usage_now(&mut self) {
157        self.usage.refresh_now();
158        self.usage_update.trigger_if_idle();
159    }
160
161    /// Signal all background threads to stop without waiting.
162    /// Call early during shutdown so threads have time to notice before the Drop join.
163    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    /// Per-frame agent-usage upkeep: watcher events, config wiring, refresh
170    /// tick, and the optional update command.
171    ///
172    /// Called unconditionally from the render pipeline every frame — NOT from
173    /// [`StatusBarUI::render`], which the caller only reaches while the status
174    /// bar is enabled. The usage panel must stay live with the bar off (it is
175    /// opened by its own action), and a watcher event arriving while the bar
176    /// is hidden must not wait for the bar to come back.
177    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        // Extra records dirs (synced from other machines) merge into the
184        // snapshot; same-set calls are a no-op inside the store.
185        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            // The refresh interval elapsed: give the optional update
203            // command its turn alongside the rescan.
204            self.usage_update.trigger_if_idle();
205        }
206    }
207
208    /// Compute the effective height consumed by the status bar.
209    ///
210    /// Returns 0 if the status bar is hidden or disabled.
211    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    /// Determine whether the status bar should be hidden right now.
220    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    /// Record mouse activity (resets auto-hide timer).
237    pub fn on_mouse_activity(&mut self) {
238        self.last_mouse_activity = Instant::now();
239        self.visible = true;
240    }
241
242    /// Start or stop the system monitor and git poller based on enabled widgets.
243    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        // System monitor
258        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        // Disk free-space monitor
272        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        // Git branch poller
289        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    /// Render the status bar.
304    ///
305    /// Returns the height consumed by the status bar (0 if hidden) and an
306    /// optional action requested by the user (e.g. clicking the update widget).
307    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        // Update git poller cwd from active tab's path
319        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        // Validate time format — update last-known-good on success, fall back on failure
328        {
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        // Build widget context
339        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        // Use an egui::Area with a fixed size so the status bar stops before
372        // the scrollbar column.  TopBottomPanel always spans the full window
373        // width and ignores every attempt to narrow it.
374        let h_margin: f32 = 8.0; // left + right inner margin per side
375        let v_margin: f32 = 2.0; // top + bottom inner margin per side
376        let scrollbar_reserved = config.scrollbar.scrollbar_width + 2.0;
377        let viewport = ctx.input(|i| i.viewport_rect());
378        // Content width is the frame width minus both horizontal margins.
379        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        // Widgets that open something on click get the same treatment in
406        // every section; all other widgets render as plain labels.
407        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            // The roster's hover carries the per-agent provenance detail the
432            // bar text cannot fit.
433            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                // Constrain the outer UI so the frame cannot grow beyond the
450                // intended total width (content + margins).
451                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                        // Clip widgets to the available content width so
460                        // right-to-left layouts cannot expand past the bar edge.
461                        ui.set_clip_rect(ui.max_rect());
462
463                        // === Left section ===
464                        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                        // === Center section ===
484                        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 &center_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                        // === Right section ===
512                        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}