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
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 &center_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}