all-smi 0.26.2

Command-line utility for monitoring GPU hardware. It provides a real-time view of GPU utilization, memory usage, temperature, power consumption, and other metrics.
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
// Copyright 2025 Lablup Inc. and Jeongkyu Shin
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Event-driven wakeup coordinator for the TUI loop.
//!
//! Replaces the fixed `event::poll(100ms)` model with a `tokio::select!`-based
//! coordinator that wakes the UI only for meaningful reasons: terminal input,
//! resize, fresh collector data, and animation ticks.

use std::sync::Arc;
use std::time::Duration;

use crossterm::event::{self, Event, KeyEventKind};
use tokio::sync::{Notify, mpsc};

use crate::common::config::AppConfig;

/// Returns `true` if a key event of the given kind should be dispatched to the
/// UI's key handler.
///
/// crossterm reports keyboard events differently per platform:
///
/// - On Unix terminals (without keyboard-enhancement flags, which all-smi does
///   not enable), only [`KeyEventKind::Press`] events are delivered. Holding a
///   key produces repeated `Press` events via the terminal's own auto-repeat.
/// - On Windows, the console backend additionally delivers
///   [`KeyEventKind::Release`] (and [`KeyEventKind::Repeat`]) events for every
///   keystroke. Forwarding all of them causes toggle bindings (Help, alerts,
///   topology mode, etc.) to fire twice and immediately undo themselves — the
///   "flash" symptom reported in issue #212.
///
/// We accept only `Press` events. This is the safe default and a no-op on Unix.
/// If held-key auto-repeat on Windows is ever desired, broaden this to accept
/// `Repeat` as well; `Release` should remain filtered.
#[inline]
fn is_actionable_key_event(kind: KeyEventKind) -> bool {
    matches!(kind, KeyEventKind::Press)
}

/// All possible reasons the UI loop should wake up and consider re-rendering.
#[derive(Debug)]
pub enum UiEvent {
    /// A terminal input event (key press, mouse click, etc.)
    TerminalInput(Event),
    /// The terminal was resized
    Resize(u16, u16),
    /// A background data collector has new data ready
    DataReady,
    /// An animation tick fired (for loading indicator, marquee scroll, clock)
    AnimationTick,
    /// The terminal reader task has exited (terminal closed or error).
    /// The UI loop should shut down gracefully.
    TerminalClosed,
}

/// Manages all event sources and delivers them through a unified channel.
///
/// The coordinator spawns a dedicated blocking task for crossterm event reading
/// (since crossterm uses synchronous I/O) and combines it with async notification
/// sources in a `tokio::select!` loop.
pub struct UiEventCoordinator {
    /// Sender passed to the terminal reader task.
    /// Stored as `Option` so we can `take()` it in `spawn_terminal_reader`,
    /// ensuring no extra sender keeps the channel open after the reader exits.
    term_tx: Option<mpsc::Sender<Event>>,
    /// Receiver for terminal events
    term_rx: mpsc::Receiver<Event>,
    /// Notification from data collectors when new data is available
    data_notify: Arc<Notify>,
    /// Animation tick interval (only active when animations are visible)
    animation_interval: tokio::time::Interval,
    /// Whether animation ticks should be active
    animations_active: bool,
}

impl UiEventCoordinator {
    /// Create a new event coordinator with the given data notification handle.
    ///
    /// `data_notify` should be shared with data collectors so they can signal
    /// the UI when fresh data is available.
    pub fn new(data_notify: Arc<Notify>) -> Self {
        // Bounded channel prevents unbounded memory growth if UI is slow.
        // 64 events is generous enough to buffer rapid keystrokes.
        let (term_tx, term_rx) = mpsc::channel::<Event>(64);

        let mut animation_interval =
            tokio::time::interval(Duration::from_millis(AppConfig::ANIMATION_TICK_MS));
        // Don't burst-fire missed ticks -- just skip them
        animation_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);

        Self {
            term_tx: Some(term_tx),
            term_rx,
            data_notify,
            animation_interval,
            animations_active: true, // Start active for loading screen
        }
    }

    /// Spawn the background terminal event reader task.
    ///
    /// This must be called once before entering the event loop. The task reads
    /// crossterm events in a blocking context and forwards them through the
    /// channel. It exits automatically when the channel is closed.
    ///
    /// The sender is *moved* into the spawned task so that the channel closes
    /// naturally when the reader exits, allowing `next_event()` to detect
    /// terminal loss via `TerminalClosed`.
    pub fn spawn_terminal_reader(&mut self) {
        let tx = self
            .term_tx
            .take()
            .expect("spawn_terminal_reader must be called exactly once");
        tokio::task::spawn_blocking(move || {
            Self::terminal_reader_loop(tx);
        });
    }

    /// Blocking loop that reads terminal events and forwards them.
    ///
    /// Uses a short poll timeout so the task can detect channel closure
    /// promptly and exit.
    fn terminal_reader_loop(tx: mpsc::Sender<Event>) {
        loop {
            // Poll with a short timeout so we can detect shutdown
            match event::poll(Duration::from_millis(AppConfig::TERMINAL_READER_POLL_MS)) {
                Ok(true) => match event::read() {
                    Ok(evt) => {
                        // Windows delivers Press *and* Release (and Repeat) for
                        // every keystroke; Unix terminals (without keyboard
                        // enhancement flags, which all-smi does not enable)
                        // deliver only Press. Forward Press-kind keys only so
                        // toggle bindings (Help, alerts, topology mode, etc.)
                        // don't fire twice and immediately undo themselves on
                        // Windows. See issue #212.
                        if let Event::Key(k) = &evt
                            && !is_actionable_key_event(k.kind)
                        {
                            continue;
                        }
                        // blocking_send is fine here -- we are in a blocking context
                        if tx.blocking_send(evt).is_err() {
                            // Receiver dropped, UI loop ended -- exit
                            break;
                        }
                    }
                    Err(_) => {
                        // Terminal read error; likely terminal gone -- exit
                        break;
                    }
                },
                Ok(false) => {
                    // No event within the poll window -- check if channel still alive
                    if tx.is_closed() {
                        break;
                    }
                }
                Err(_) => {
                    // Poll error -- exit
                    break;
                }
            }
        }
    }

    /// Set whether fast animation ticks should fire.
    ///
    /// When `active` is true, ticks fire at `ANIMATION_TICK_MS` (200ms) for
    /// smooth marquee and loading animations. When false, ticks fire at
    /// `REFRESH_TICK_MS` (1s) for clock updates and periodic refresh.
    /// The tick is never fully disabled so the display stays alive.
    pub fn set_animations_active(&mut self, active: bool) {
        if active != self.animations_active {
            self.animations_active = active;
            let new_period = if active {
                Duration::from_millis(AppConfig::ANIMATION_TICK_MS)
            } else {
                Duration::from_millis(AppConfig::REFRESH_TICK_MS)
            };
            self.animation_interval = tokio::time::interval(new_period);
            self.animation_interval
                .set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
        }
    }

    /// Wait for the next UI event from any source.
    ///
    /// This is the main select point. It sleeps efficiently until one of the
    /// registered sources has something to deliver. When multiple sources fire
    /// simultaneously, `tokio::select!` picks one at random, ensuring fairness.
    ///
    /// Returns `TerminalClosed` when the terminal reader task has exited,
    /// Drain all pending terminal events from the channel without blocking.
    /// Returns them as a vector so the caller can batch-process them
    /// and render only once at the end.
    pub fn drain_pending_events(&mut self) -> Vec<UiEvent> {
        let mut events = Vec::new();
        while let Ok(event) = self.term_rx.try_recv() {
            match event {
                Event::Resize(w, h) => events.push(UiEvent::Resize(w, h)),
                other => events.push(UiEvent::TerminalInput(other)),
            }
        }
        events
    }

    /// signalling that the UI loop should shut down.
    pub async fn next_event(&mut self) -> UiEvent {
        tokio::select! {
            // Branch 1: terminal input/resize from the blocking reader.
            // When the channel closes (reader exited), recv() returns None
            // and we signal TerminalClosed for graceful shutdown.
            result = self.term_rx.recv() => {
                match result {
                    Some(Event::Resize(w, h)) => UiEvent::Resize(w, h),
                    Some(other) => UiEvent::TerminalInput(other),
                    None => UiEvent::TerminalClosed,
                }
            }

            // Branch 2: data collector notification
            _ = self.data_notify.notified() => {
                UiEvent::DataReady
            }

            // Branch 3: periodic tick (fast for animations, slow for clock-only refresh)
            _ = self.animation_interval.tick() => {
                UiEvent::AnimationTick
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
    use std::sync::Arc;
    use std::time::Duration;
    use tokio::sync::Notify;

    // -----------------------------------------------------------------------
    // UiEvent: basic enum behaviour
    // -----------------------------------------------------------------------

    #[test]
    fn test_ui_event_debug_variants() {
        // Verify Debug can be derived for all variants without panicking.
        let events: Vec<UiEvent> = vec![
            UiEvent::DataReady,
            UiEvent::AnimationTick,
            UiEvent::TerminalClosed,
            UiEvent::Resize(80, 24),
        ];
        for e in events {
            let _ = format!("{e:?}");
        }
    }

    #[test]
    fn test_resize_event_carries_dimensions() {
        let ev = UiEvent::Resize(120, 40);
        match ev {
            UiEvent::Resize(w, h) => {
                assert_eq!(w, 120);
                assert_eq!(h, 40);
            }
            _ => panic!("Expected Resize variant"),
        }
    }

    // -----------------------------------------------------------------------
    // UiEventCoordinator: construction
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn test_coordinator_new_does_not_panic() {
        let notify = Arc::new(Notify::new());
        let _coordinator = UiEventCoordinator::new(notify);
    }

    #[tokio::test]
    async fn test_coordinator_animations_active_by_default() {
        // The coordinator starts with animations active so the loading screen
        // shows its spinner immediately.  Drop it to confirm no panic occurs.
        let notify = Arc::new(Notify::new());
        let coordinator = UiEventCoordinator::new(notify);
        drop(coordinator);
    }

    // -----------------------------------------------------------------------
    // UiEventCoordinator: set_animations_active
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn test_set_animations_active_toggle() {
        let notify = Arc::new(Notify::new());
        let mut coordinator = UiEventCoordinator::new(notify);

        // Toggle off and on; neither call should panic.
        coordinator.set_animations_active(false);
        coordinator.set_animations_active(true);
        coordinator.set_animations_active(false);
    }

    // -----------------------------------------------------------------------
    // UiEventCoordinator: DataReady path
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn test_next_event_data_ready() {
        let notify = Arc::new(Notify::new());
        let mut coordinator = UiEventCoordinator::new(Arc::clone(&notify));
        // Disable animation ticks so they don't race with our assertion.
        coordinator.set_animations_active(false);

        // Pre-notify before calling next_event so there is no blocking wait.
        notify.notify_one();

        let event = tokio::time::timeout(Duration::from_secs(1), coordinator.next_event())
            .await
            .expect("next_event timed out");

        assert!(
            matches!(event, UiEvent::DataReady),
            "Expected DataReady, got {event:?}"
        );
    }

    // -----------------------------------------------------------------------
    // UiEventCoordinator: TerminalClosed path
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn test_next_event_terminal_closed_when_channel_drops() {
        let notify = Arc::new(Notify::new());
        let mut coordinator = UiEventCoordinator::new(Arc::clone(&notify));
        coordinator.set_animations_active(false);

        // spawn_terminal_reader consumes the internal sender by moving it into
        // the blocking task.  That task will try to read from the real terminal
        // and exit quickly when the channel receiver is gone; however in a test
        // environment the reader task may linger until the poll timeout fires.
        // Instead of relying on the real reader, we simulate channel closure by
        // dropping the coordinator's term_tx via spawn_terminal_reader and then
        // waiting for the coordinator to observe the closed channel.
        coordinator.spawn_terminal_reader();

        // Drive next_event in a loop; the blocking task will eventually close
        // the channel sender (it exits when tx.blocking_send fails or the poll
        // timeout elapses and tx.is_closed() returns true).
        let event = tokio::time::timeout(Duration::from_secs(2), async {
            loop {
                match coordinator.next_event().await {
                    UiEvent::TerminalClosed => return UiEvent::TerminalClosed,
                    // Animation ticks or DataReady may fire before the reader
                    // task exits; skip them.
                    _ => continue,
                }
            }
        })
        .await
        .expect("TerminalClosed event never arrived");

        assert!(matches!(event, UiEvent::TerminalClosed));
    }

    // -----------------------------------------------------------------------
    // Event mapping logic (unit-tested in isolation)
    // -----------------------------------------------------------------------

    /// Verify that the Event::Resize mapping logic used inside next_event
    /// produces UiEvent::Resize with correct dimensions.
    #[test]
    fn test_resize_event_mapping() {
        let crossterm_event = Event::Resize(100, 50);
        let ui_event = match crossterm_event {
            Event::Resize(w, h) => UiEvent::Resize(w, h),
            other => UiEvent::TerminalInput(other),
        };
        assert!(matches!(ui_event, UiEvent::Resize(100, 50)));
    }

    /// Verify that a non-Resize crossterm event is wrapped in TerminalInput.
    #[test]
    fn test_terminal_input_wrapping() {
        let key_event = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
        let ui_event = match key_event {
            Event::Resize(w, h) => UiEvent::Resize(w, h),
            other => UiEvent::TerminalInput(other),
        };
        assert!(
            matches!(ui_event, UiEvent::TerminalInput(_)),
            "Expected TerminalInput, got {ui_event:?}"
        );
    }

    // -----------------------------------------------------------------------
    // AppConfig: event-driven constants are sane
    // -----------------------------------------------------------------------

    #[test]
    fn test_animation_tick_ms_reasonable() {
        // Must be positive and not unreasonably large.
        const { assert!(AppConfig::ANIMATION_TICK_MS > 0) };
        const { assert!(AppConfig::ANIMATION_TICK_MS <= 500) };
    }

    #[test]
    fn test_terminal_reader_poll_ms_reasonable() {
        // Must be positive and not so large that shutdown detection is sluggish.
        const { assert!(AppConfig::TERMINAL_READER_POLL_MS > 0) };
        const { assert!(AppConfig::TERMINAL_READER_POLL_MS <= 200) };
    }

    #[test]
    fn test_animation_tick_ms_value() {
        assert_eq!(AppConfig::ANIMATION_TICK_MS, 200);
    }

    #[test]
    fn test_terminal_reader_poll_ms_value() {
        assert_eq!(AppConfig::TERMINAL_READER_POLL_MS, 50);
    }

    // -----------------------------------------------------------------------
    // DataCollector: notify_ui wiring
    // -----------------------------------------------------------------------

    /// Verify that DataCollector::with_notify stores the notify handle and
    /// that the coordinator observes a DataReady event when the collector
    /// calls notify_one() on the shared handle.
    #[tokio::test]
    async fn test_data_collector_notify_wiring() {
        use crate::app_state::AppState;
        use crate::view::data_collector::DataCollector;
        use tokio::sync::Mutex;

        let app_state = Arc::new(Mutex::new(AppState::new()));
        let notify = Arc::new(Notify::new());

        // Build collector and coordinator sharing the same notify.
        let _collector = DataCollector::with_notify(Arc::clone(&app_state), Arc::clone(&notify));
        let mut coordinator = UiEventCoordinator::new(Arc::clone(&notify));
        coordinator.set_animations_active(false);

        // Signal as the collector would after a successful data update.
        notify.notify_one();

        let event = tokio::time::timeout(Duration::from_millis(200), coordinator.next_event())
            .await
            .expect("DataReady event never arrived");

        assert!(
            matches!(event, UiEvent::DataReady),
            "Expected DataReady, got {event:?}"
        );
    }

    // -----------------------------------------------------------------------
    // is_actionable_key_event: Windows press/release double-dispatch guard
    // (regression test for issue #212).
    // -----------------------------------------------------------------------

    /// Build a key event with an explicit kind. `KeyEvent::new` defaults `kind`
    /// to `Press`, so a `Release`/`Repeat` event must be constructed via the
    /// fully-qualified constructor.
    fn key_event_with_kind(kind: KeyEventKind) -> Event {
        Event::Key(KeyEvent::new_with_kind_and_state(
            KeyCode::Char('h'),
            KeyModifiers::NONE,
            kind,
            KeyEventState::NONE,
        ))
    }

    #[test]
    fn test_is_actionable_key_event_accepts_press() {
        assert!(is_actionable_key_event(KeyEventKind::Press));
    }

    #[test]
    fn test_is_actionable_key_event_rejects_release() {
        // The Windows-only double-dispatch that issue #212 fixes.
        assert!(!is_actionable_key_event(KeyEventKind::Release));
    }

    #[test]
    fn test_is_actionable_key_event_rejects_repeat() {
        // Holding a key on Windows produces Repeat events; we treat the
        // terminal's own auto-repeat (Press cadence) as the source of truth.
        assert!(!is_actionable_key_event(KeyEventKind::Repeat));
    }

    /// The reader-loop filter must drop non-`Press` key events before they
    /// reach the coordinator's channel. We mirror that filter here so the
    /// regression is locked at the logic level (the reader loop reads from a
    /// real terminal, which is unavailable in unit tests).
    #[test]
    fn test_reader_filter_drops_release_events() {
        let release = key_event_with_kind(KeyEventKind::Release);
        let should_forward = match &release {
            Event::Key(k) => is_actionable_key_event(k.kind),
            _ => true,
        };
        assert!(
            !should_forward,
            "Release key events must be filtered out (issue #212)"
        );
    }

    /// Non-key events (mouse, resize, focus, paste) must pass through the
    /// filter untouched — only `Event::Key` is gated by `KeyEventKind`.
    #[test]
    fn test_reader_filter_passes_non_key_events() {
        let resize = Event::Resize(80, 24);
        let should_forward = match &resize {
            Event::Key(k) => is_actionable_key_event(k.kind),
            _ => true,
        };
        assert!(
            should_forward,
            "Non-key events must not be filtered by the key-kind guard"
        );
    }

    /// A normal `Press` event must still be forwarded — the filter must not
    /// regress Unix behavior where every key event is already `Press`.
    #[test]
    fn test_reader_filter_passes_press_events() {
        let press = key_event_with_kind(KeyEventKind::Press);
        let should_forward = match &press {
            Event::Key(k) => is_actionable_key_event(k.kind),
            _ => true,
        };
        assert!(
            should_forward,
            "Press key events must be forwarded (Unix parity)"
        );
    }
}