par-term 0.47.0

Cross-platform GPU-accelerated terminal emulator with inline graphics support (Sixel, iTerm2, Kitty)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
//! Status bar system for displaying session and system information.
//!
//! The status bar is a configurable panel that can display widgets such as
//! the current time, username, git branch, CPU/memory usage, and more.
//!
//! # Widget Architecture
//!
//! The current architecture uses a **data-driven, direct-call approach** rather
//! than a formal registration mechanism:
//!
//! - Widget identifiers (`WidgetId`) and their configuration are defined in
//!   `par-term-config/src/status_bar.rs`.
//! - The `widgets` submodule (`src/status_bar/widgets/`) contains the rendering
//!   logic: `widget_text()` dispatches on `WidgetId` to produce the display string,
//!   and `sorted_widgets_for_section()` filters and orders widgets for each bar section.
//! - `StatusBarUI::render()` in this file drives the egui layout for left/center/right
//!   sections and calls into the widgets module.
//! - Background data (system metrics, git status) is polled on dedicated threads
//!   (`SystemMonitor`, `GitBranchPoller`) and surfaced via `WidgetContext`.
//!
//! # Adding a New Widget
//!
//! 1. Add a variant to `WidgetId` in `par-term-config/src/status_bar.rs`.
//! 2. Add a `widget_text()` arm in `src/status_bar/widgets/`.
//! 3. If the widget needs background data, extend `WidgetContext` and add
//!    a poller in this file following the `GitBranchPoller` pattern.
//! 4. Update `default_widgets()` in `par-term-config` to include the new widget.
//! 5. Update search keywords in `par-term-settings-ui/src/settings_ui/sidebar.rs`.
//!
//! # Future: Formal Widget Registry
//!
//! The current dispatch-on-enum approach scales well to ~20 widgets but becomes
//! harder to extend as the widget set grows. A future improvement could introduce
//! a `StatusBarWidget` trait and a registry (e.g., `HashMap<WidgetId, Box<dyn StatusBarWidget>>`)
//! so that third-party or plugin-style widgets can be registered without modifying
//! the central dispatch function. This is tracked as ARC-009 in AUDIT.md.

pub mod disk_monitor;
pub mod git_poller;
pub mod plugins_upkeep;
pub mod system_monitor;
pub mod widgets;

#[cfg(test)]
mod widget_click_tests;

use std::collections::HashMap;
use std::time::{Duration, Instant};

use crate::agent_usage::store::UsageStore;
use crate::agent_usage::update::UpdateRunner;
use crate::badge::SessionVariables;
use crate::config::{Config, StatusBarPosition, StatusBarSection, StatusBarWidgetConfig};
use disk_monitor::DiskMonitor;
use git_poller::GitBranchPoller;
use par_term_scripting::plugin_manager::{PluginHost, WarnOnce};
use system_monitor::SystemMonitor;
use widgets::{WidgetContext, sorted_widgets_for_section, widget_text};

pub use git_poller::GitStatus;

/// Floor for the configured agent-usage refresh interval.
const AGENT_USAGE_MIN_REFRESH: Duration = Duration::from_secs(30);

/// Actions that the status bar can request from the window.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StatusBarAction {
    /// User clicked the update-available widget.
    ShowUpdateDialog,
    /// User clicked the agent-usage widget.
    OpenAgentUsagePanel,
    /// User clicked the agent-roster widget; opens the command palette (the
    /// roster's list surface, per the A2b design — not a separate panel).
    OpenAgentPalette,
}

/// Status bar UI state and renderer.
pub struct StatusBarUI {
    /// Background system resource monitor.
    system_monitor: SystemMonitor,
    /// Background disk free-space monitor.
    disk_monitor: DiskMonitor,
    /// Git branch poller.
    git_poller: GitBranchPoller,
    /// Timestamp of the last mouse activity (for auto-hide).
    last_mouse_activity: Instant,
    /// Whether the status bar is currently visible.
    visible: bool,
    /// Last valid time format string (for fallback when user is mid-edit).
    last_valid_time_format: String,
    /// Available update version (set by WindowManager when update is detected)
    pub update_available_version: Option<String>,
    /// Agent-roster summary line for the status-bar widget (A2b task 2),
    /// refreshed by the render pipeline from the mux roster cache before the
    /// egui borrow. `None` (no mux session / empty roster) self-hides the
    /// widget.
    pub agent_roster_summary: Option<String>,
    /// Hover text for the agent-roster widget; `None` hides the tooltip.
    pub agent_roster_tooltip: Option<String>,
    /// Agent-usage records store (watched directory + snapshot). Lives here
    /// with the other background-data pollers; the popup panel reads it via
    /// [`StatusBarUI::usage_snapshot`].
    pub(crate) usage: UsageStore,
    /// Optional agent-usage update command, run on the refresh interval.
    usage_update: UpdateRunner,
    /// Window-scoped plugin host: discovery cache, supervised processes,
    /// and the last `SetWidget` text per plugin (v1 plugins are bar widgets).
    plugins: PluginHost,
    /// When the plugins root was last scanned; `None` until the first
    /// [`update_plugins`](StatusBarUI::update_plugins) call performs it.
    plugins_last_discovery: Option<Instant>,
    /// Warn-once gate for per-frame invalid-settings warnings (upkeep runs
    /// every render frame; a steady fault must not warn per frame).
    plugin_settings_warned: WarnOnce,
    /// Terminal-observer registrations for plugin event forwarders, keyed by
    /// (plugin id, tab id, terminal slot): `None` names the tab terminal,
    /// `Some(pane id)` a pane terminal of a mux tab. Reconciled every sweep
    /// by [`StatusBarUI::pump_plugin_events`]; an entry for a closed tab or
    /// pane is dropped with the map entry alone — its terminal, and the
    /// observer registry inside it, is gone with it.
    plugin_observer_ids: HashMap<
        (String, crate::tab::TabId, Option<crate::pane::PaneId>),
        par_term_emu_core_rust::observer::ObserverId,
    >,
}

impl StatusBarUI {
    /// Create a new status bar UI.
    pub fn new() -> Self {
        Self {
            system_monitor: SystemMonitor::new(),
            disk_monitor: DiskMonitor::new(),
            git_poller: GitBranchPoller::new(),
            last_mouse_activity: Instant::now(),
            visible: true,
            last_valid_time_format: "%H:%M:%S".to_string(),
            update_available_version: None,
            agent_roster_summary: None,
            agent_roster_tooltip: None,
            usage: UsageStore::new(crate::agent_usage::default_records_dir()),
            usage_update: UpdateRunner::new(),
            plugins: PluginHost::new(),
            plugins_last_discovery: None,
            plugin_settings_warned: WarnOnce::default(),
            plugin_observer_ids: HashMap::new(),
        }
    }

    /// Read access to the agent-usage snapshot for the popup panel.
    pub(crate) fn usage_snapshot(&self) -> &crate::agent_usage::store::UsageSnapshot {
        self.usage.snapshot()
    }

    /// Rescan the usage records directory now (panel `r`), and give the
    /// optional update command its manual-refresh turn.
    pub(crate) fn refresh_usage_now(&mut self) {
        self.usage.refresh_now();
        self.usage_update.trigger_if_idle();
    }

    /// Signal all background threads to stop without waiting.
    /// Call early during shutdown so threads have time to notice before the Drop join.
    pub fn signal_shutdown(&self) {
        self.system_monitor.signal_stop();
        self.disk_monitor.signal_stop();
        self.git_poller.signal_stop();
    }

    /// Per-frame agent-usage upkeep: watcher events, config wiring, refresh
    /// tick, and the optional update command.
    ///
    /// Called unconditionally from the render pipeline every frame — NOT from
    /// [`StatusBarUI::render`], which the caller only reaches while the status
    /// bar is enabled. The usage panel must stay live with the bar off (it is
    /// opened by its own action), and a watcher event arriving while the bar
    /// is hidden must not wait for the bar to come back.
    pub(crate) fn update_agent_usage(&mut self, config: &Config) {
        if !config.agent_usage.agent_usage_enabled {
            return;
        }
        self.usage.poll();
        self.usage.set_hidden(config.agent_usage.hidden_set());
        // Extra records dirs (synced from other machines) merge into the
        // snapshot; same-set calls are a no-op inside the store.
        self.usage.set_extra_dirs(
            config
                .agent_usage
                .agent_usage_extra_records_dirs
                .iter()
                .map(|p| crate::agent_usage::expand_tilde(p))
                .collect(),
        );
        self.usage_update
            .configure(config.agent_usage.agent_usage_update_command.clone());
        let interval = Duration::from_secs(
            config
                .agent_usage
                .agent_usage_refresh_interval_sec
                .max(AGENT_USAGE_MIN_REFRESH.as_secs()),
        );
        if self.usage.tick(interval) {
            // The refresh interval elapsed: give the optional update
            // command its turn alongside the rescan.
            self.usage_update.trigger_if_idle();
        }
    }

    /// Compute the effective height consumed by the status bar.
    ///
    /// Returns 0 if the status bar is hidden or disabled.
    pub fn height(&self, config: &Config, is_fullscreen: bool) -> f32 {
        if !config.status_bar.status_bar_enabled || self.should_hide(config, is_fullscreen) {
            0.0
        } else {
            config.status_bar.status_bar_height
        }
    }

    /// Determine whether the status bar should be hidden right now.
    pub fn should_hide(&self, config: &Config, is_fullscreen: bool) -> bool {
        if !config.status_bar.status_bar_enabled {
            return true;
        }
        if config.status_bar.status_bar_auto_hide_fullscreen && is_fullscreen {
            return true;
        }
        if config.status_bar.status_bar_auto_hide_mouse_inactive {
            let elapsed = self.last_mouse_activity.elapsed().as_secs_f32();
            if elapsed > config.status_bar.status_bar_mouse_inactive_timeout {
                return true;
            }
        }
        false
    }

    /// Record mouse activity (resets auto-hide timer).
    pub fn on_mouse_activity(&mut self) {
        self.last_mouse_activity = Instant::now();
        self.visible = true;
    }

    /// Start or stop the system monitor and git poller based on enabled widgets.
    pub fn sync_monitor_state(&self, config: &Config) {
        if !config.status_bar.status_bar_enabled {
            if self.system_monitor.is_running() {
                self.system_monitor.stop();
            }
            if self.disk_monitor.is_running() {
                self.disk_monitor.stop();
            }
            if self.git_poller.is_running() {
                self.git_poller.stop();
            }
            return;
        }

        // System monitor
        let needs_monitor = config
            .status_bar
            .status_bar_widgets
            .iter()
            .any(|w| w.enabled && w.id.needs_system_monitor());

        if needs_monitor && !self.system_monitor.is_running() {
            self.system_monitor
                .start(config.status_bar.status_bar_system_poll_interval);
        } else if !needs_monitor && self.system_monitor.is_running() {
            self.system_monitor.stop();
        }

        // Disk free-space monitor
        let needs_disk = config
            .status_bar
            .status_bar_widgets
            .iter()
            .any(|w| w.enabled && w.id.needs_disk_monitor());

        self.disk_monitor
            .set_follow_cwd(config.status_bar.status_bar_disk_follow_cwd);

        if needs_disk && !self.disk_monitor.is_running() {
            self.disk_monitor
                .start(config.status_bar.status_bar_disk_poll_interval);
        } else if !needs_disk && self.disk_monitor.is_running() {
            self.disk_monitor.stop();
        }

        // Git branch poller
        let needs_git = config
            .status_bar
            .status_bar_widgets
            .iter()
            .any(|w| w.enabled && w.id == crate::config::WidgetId::GitBranch);

        if needs_git && !self.git_poller.is_running() {
            self.git_poller
                .start(config.status_bar.status_bar_git_poll_interval);
        } else if !needs_git && self.git_poller.is_running() {
            self.git_poller.stop();
        }
    }

    /// Render the status bar.
    ///
    /// Returns the height consumed by the status bar (0 if hidden) and an
    /// optional action requested by the user (e.g. clicking the update widget).
    pub fn render(
        &mut self,
        ctx: &egui::Context,
        config: &Config,
        session_vars: &SessionVariables,
        is_fullscreen: bool,
    ) -> (f32, Option<StatusBarAction>) {
        if !config.status_bar.status_bar_enabled || self.should_hide(config, is_fullscreen) {
            return (0.0, None);
        }

        // Update git poller cwd from active tab's path
        let cwd = if session_vars.path.is_empty() {
            None
        } else {
            Some(session_vars.path.as_str())
        };
        self.git_poller.set_cwd(cwd);
        self.disk_monitor.set_cwd(cwd);

        // Validate time format — update last-known-good on success, fall back on failure
        {
            use chrono::format::strftime::StrftimeItems;
            let valid = !config.status_bar.status_bar_time_format.is_empty()
                && StrftimeItems::new(&config.status_bar.status_bar_time_format)
                    .all(|item| !matches!(item, chrono::format::Item::Error));
            if valid {
                self.last_valid_time_format = config.status_bar.status_bar_time_format.clone();
            }
        }

        // Build widget context
        let git_status = self.git_poller.status();
        let disk_data = self.disk_monitor.data();
        let widget_ctx = WidgetContext {
            session_vars: session_vars.clone(),
            system_data: self.system_monitor.data(),
            git_branch: git_status.branch,
            git_ahead: git_status.ahead,
            git_behind: git_status.behind,
            git_dirty: git_status.dirty,
            git_show_status: config.status_bar.status_bar_git_show_status,
            time_format: self.last_valid_time_format.clone(),
            update_available_version: self.update_available_version.clone(),
            disk_free_percent: disk_data.free_percent,
            disk_free_bytes: disk_data.free_bytes,
            disk_total_bytes: disk_data.total_bytes,
            agent_usage_summary: self.usage.summary_line(),
            agent_roster_summary: self.agent_roster_summary.clone(),
            agent_roster_tooltip: self.agent_roster_tooltip.clone(),
            plugin_texts: self.plugins.widget_texts().clone(),
        };

        let bar_height = config.status_bar.status_bar_height;
        let [bg_r, bg_g, bg_b] = config.status_bar.status_bar_bg_color;
        let bg_alpha = (config.status_bar.status_bar_bg_alpha * 255.0) as u8;
        let bg_color = egui::Color32::from_rgba_unmultiplied(bg_r, bg_g, bg_b, bg_alpha);

        let [fg_r, fg_g, fg_b] = config.status_bar.status_bar_fg_color;
        let fg_color = egui::Color32::from_rgb(fg_r, fg_g, fg_b);
        let font_size = config.status_bar.status_bar_font_size;
        let separator = &config.status_bar.status_bar_separator;
        let sep_color = fg_color.linear_multiply(0.4);

        // Use an egui::Area with a fixed size so the status bar stops before
        // the scrollbar column.  TopBottomPanel always spans the full window
        // width and ignores every attempt to narrow it.
        let h_margin: f32 = 8.0; // left + right inner margin per side
        let v_margin: f32 = 2.0; // top + bottom inner margin per side
        let scrollbar_reserved = config.scrollbar.scrollbar_width + 2.0;
        let viewport = ctx.input(|i| i.viewport_rect());
        // Content width is the frame width minus both horizontal margins.
        let content_width = (viewport.width() - scrollbar_reserved - h_margin * 2.0).max(0.0);
        let content_height = (bar_height - v_margin * 2.0).max(0.0);

        let bar_pos = match config.status_bar.status_bar_position {
            StatusBarPosition::Top => egui::pos2(0.0, 0.0),
            StatusBarPosition::Bottom => egui::pos2(0.0, viewport.height() - bar_height),
        };

        let frame = egui::Frame::NONE
            .fill(bg_color)
            .inner_margin(egui::Margin::symmetric(h_margin as i8, v_margin as i8));

        let make_rich_text = |text: &str| -> egui::RichText {
            egui::RichText::new(text)
                .color(fg_color)
                .size(font_size)
                .monospace()
        };

        let make_sep = |sep: &str| -> egui::RichText {
            egui::RichText::new(sep)
                .color(sep_color)
                .size(font_size)
                .monospace()
        };

        // Widgets that open something on click get the same treatment in
        // every section; all other widgets render as plain labels.
        let roster_tooltip = self.agent_roster_tooltip.clone();
        let render_widget = |ui: &mut egui::Ui,
                             w: &StatusBarWidgetConfig,
                             text: &str|
         -> Option<StatusBarAction> {
            use crate::config::WidgetId;
            let (rich, action) = match w.id {
                WidgetId::UpdateAvailable => (
                    egui::RichText::new(text)
                        .color(egui::Color32::from_rgb(255, 200, 50))
                        .size(font_size)
                        .monospace(),
                    StatusBarAction::ShowUpdateDialog,
                ),
                WidgetId::AgentUsage => {
                    (make_rich_text(text), StatusBarAction::OpenAgentUsagePanel)
                }
                WidgetId::AgentRoster => (make_rich_text(text), StatusBarAction::OpenAgentPalette),
                _ => {
                    ui.label(make_rich_text(text));
                    return None;
                }
            };
            let response = ui.add(egui::Label::new(rich).sense(egui::Sense::click()));
            // The roster's hover carries the per-agent provenance detail the
            // bar text cannot fit.
            if w.id == WidgetId::AgentRoster
                && let Some(tooltip) = roster_tooltip.as_deref()
                && !tooltip.is_empty()
            {
                return response.on_hover_text(tooltip).clicked().then_some(action);
            }
            response.clicked().then_some(action)
        };

        let mut action: Option<StatusBarAction> = None;

        egui::Area::new(egui::Id::new("status_bar"))
            .fixed_pos(bar_pos)
            .order(egui::Order::Background)
            .interactable(true)
            .show(ctx, |ui| {
                // Constrain the outer UI so the frame cannot grow beyond the
                // intended total width (content + margins).
                ui.set_max_width(content_width + h_margin * 2.0);
                ui.set_max_height(bar_height);

                frame.show(ui, |ui| {
                    ui.set_min_size(egui::vec2(content_width, content_height));
                    ui.set_max_size(egui::vec2(content_width, content_height));

                    ui.horizontal_centered(|ui| {
                        // Clip widgets to the available content width so
                        // right-to-left layouts cannot expand past the bar edge.
                        ui.set_clip_rect(ui.max_rect());

                        // === Left section ===
                        let left_widgets = sorted_widgets_for_section(
                            &config.status_bar.status_bar_widgets,
                            StatusBarSection::Left,
                        );
                        let mut first = true;
                        for w in &left_widgets {
                            let text = widget_text(&w.id, &widget_ctx, w.format.as_deref());
                            if text.is_empty() {
                                continue;
                            }
                            if !first {
                                ui.label(make_sep(separator));
                            }
                            first = false;
                            if let Some(a) = render_widget(ui, w, &text) {
                                action = Some(a);
                            }
                        }

                        // === Center section ===
                        let center_widgets = sorted_widgets_for_section(
                            &config.status_bar.status_bar_widgets,
                            StatusBarSection::Center,
                        );
                        if !center_widgets.is_empty() {
                            ui.with_layout(
                                egui::Layout::centered_and_justified(egui::Direction::LeftToRight),
                                |ui| {
                                    let mut first = true;
                                    for w in &center_widgets {
                                        let text =
                                            widget_text(&w.id, &widget_ctx, w.format.as_deref());
                                        if text.is_empty() {
                                            continue;
                                        }
                                        if !first {
                                            ui.label(make_sep(separator));
                                        }
                                        first = false;
                                        if let Some(a) = render_widget(ui, w, &text) {
                                            action = Some(a);
                                        }
                                    }
                                },
                            );
                        }

                        // === Right section ===
                        let right_widgets = sorted_widgets_for_section(
                            &config.status_bar.status_bar_widgets,
                            StatusBarSection::Right,
                        );
                        if !right_widgets.is_empty() {
                            ui.with_layout(
                                egui::Layout::right_to_left(egui::Align::Center),
                                |ui| {
                                    let mut first = true;
                                    for w in right_widgets.iter().rev() {
                                        let text =
                                            widget_text(&w.id, &widget_ctx, w.format.as_deref());
                                        if text.is_empty() {
                                            continue;
                                        }
                                        if !first {
                                            ui.label(make_sep(separator));
                                        }
                                        first = false;
                                        if let Some(a) = render_widget(ui, w, &text) {
                                            action = Some(a);
                                        }
                                    }
                                },
                            );
                        }
                    });
                });
            });

        (bar_height, action)
    }
}

impl Default for StatusBarUI {
    fn default() -> Self {
        Self::new()
    }
}

impl Drop for StatusBarUI {
    fn drop(&mut self) {
        self.system_monitor.stop();
        self.disk_monitor.stop();
    }
}