Skip to main content

bee_tui/
app.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
4
5use color_eyre::eyre::eyre;
6use crossterm::event::KeyEvent;
7use ratatui::prelude::Rect;
8use serde::{Deserialize, Serialize};
9use tokio::sync::{mpsc, watch};
10use tokio_util::sync::CancellationToken;
11use tracing::{debug, info};
12
13use crate::{
14    action::Action,
15    api::ApiClient,
16    bee_supervisor::{BeeStatus, BeeSupervisor},
17    components::{
18        Component,
19        api_health::ApiHealth,
20        health::{Gate, GateStatus, Health},
21        log_pane::{BeeLogLine, LogPane, LogTab},
22        lottery::Lottery,
23        network::Network,
24        peers::Peers,
25        pins::Pins,
26        stamps::Stamps,
27        swap::Swap,
28        tags::Tags,
29        warmup::Warmup,
30    },
31    config::Config,
32    log_capture, stamp_preview,
33    state::State,
34    theme,
35    tui::{Event, Tui},
36    watch::{BeeWatch, HealthSnapshot, RefreshProfile},
37};
38
39pub struct App {
40    config: Config,
41    tick_rate: f64,
42    frame_rate: f64,
43    /// Top-level screens, in display order. Tab cycles among them.
44    /// v0.4 also wires the k9s-style `:command` switcher so users
45    /// can jump directly with `:peers`, `:stamps`, etc.
46    screens: Vec<Box<dyn Component>>,
47    /// Index into [`Self::screens`] for the currently visible screen.
48    current_screen: usize,
49    /// Always-on bottom strip; not part of `screens` because it
50    /// renders alongside whatever screen is active. Tabbed across
51    /// Errors/Warn/Info/Debug/BeeHttp/SelfHttp.
52    log_pane: LogPane,
53    /// Where the persisted UI state (tab + height) lives on disk.
54    /// Computed once at startup; rewritten on quit.
55    state_path: PathBuf,
56    should_quit: bool,
57    should_suspend: bool,
58    mode: Mode,
59    last_tick_key_events: Vec<KeyEvent>,
60    action_tx: mpsc::UnboundedSender<Action>,
61    action_rx: mpsc::UnboundedReceiver<Action>,
62    /// Root cancellation token. Children: BeeWatch hub → per-resource
63    /// pollers. Cancelling this on quit unwinds every spawned task.
64    root_cancel: CancellationToken,
65    /// Active Bee node connection; cheap to clone (`Arc<Inner>` under
66    /// the hood). Read by future header bar + multi-node switcher.
67    #[allow(dead_code)]
68    api: Arc<ApiClient>,
69    /// Watch / informer hub feeding screens.
70    watch: BeeWatch,
71    /// Top-bar reuses the health snapshot for the live ping
72    /// indicator. Cheap clone of the watch receiver.
73    health_rx: watch::Receiver<HealthSnapshot>,
74    /// `Some(buf)` while the user is typing a `:command`. The
75    /// buffer holds the characters typed *after* the leading colon.
76    command_buffer: Option<String>,
77    /// Index into the *filtered* command-suggestion list of the row
78    /// currently highlighted by the Up/Down keys. Reset to 0 on every
79    /// buffer mutation so a fresh prefix always starts at the top
80    /// match.
81    command_suggestion_index: usize,
82    /// Status / error from the most recent `:command`, persisted on
83    /// the command-bar line until the user enters command mode again.
84    /// Cleared when `command_buffer` transitions to `Some`.
85    command_status: Option<CommandStatus>,
86    /// `true` while the `?` help overlay is up. Renders on top of
87    /// the active screen; `?` toggles, `Esc` dismisses.
88    help_visible: bool,
89    /// Tracks the moment the operator pressed `q` once. A second
90    /// `q` within [`QUIT_CONFIRM_WINDOW`] commits the quit; otherwise
91    /// it expires and the cockpit keeps running. Prevents a single
92    /// stray keystroke from killing a session the operator is
93    /// actively monitoring.
94    quit_pending: Option<Instant>,
95    /// `Some` when the `[bee]` block (or `--bee-bin` / `--bee-config`)
96    /// is configured and we're acting as Bee's parent process. `None`
97    /// for the legacy "connect to a running Bee" flow.
98    supervisor: Option<BeeSupervisor>,
99    /// Last-observed status of the supervised Bee child. Refreshed
100    /// each Tick from `supervisor.status()`. Surfaced in the top bar
101    /// so a mid-session crash is visible to the operator (variant B
102    /// of the crash-handling spec — show, don't auto-restart).
103    bee_status: BeeStatus,
104    /// Receiver paired with the bee-log tailer task. `None` when
105    /// the cockpit isn't acting as the supervisor (no log file to
106    /// tail). Drained on each Tick into the LogPane.
107    bee_log_rx: Option<mpsc::UnboundedReceiver<(LogTab, BeeLogLine)>>,
108    /// Channel for async-completing `:command` results. Verbs that
109    /// can't return their answer synchronously (e.g. `:probe-upload`
110    /// which has to wait on an HTTP round-trip) hand a clone of the
111    /// sender to a tokio task and surface the outcome on completion;
112    /// the App drains this on every Tick into `command_status`.
113    cmd_status_tx: mpsc::UnboundedSender<CommandStatus>,
114    cmd_status_rx: mpsc::UnboundedReceiver<CommandStatus>,
115}
116
117/// Window during which a second `q` press is interpreted as confirming
118/// the quit. After this elapses the first press is forgotten.
119const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
120
121/// Outcome from the most recently executed `:command`. Drives the
122/// colour of the command-bar line in normal mode.
123#[derive(Debug, Clone)]
124pub enum CommandStatus {
125    Info(String),
126    Err(String),
127}
128
129/// Names the top-level screens. Index matches position in
130/// [`App::screens`].
131const SCREEN_NAMES: &[&str] = &[
132    "Health", "Stamps", "Swap", "Lottery", "Peers", "Network", "Warmup", "API", "Tags", "Pins",
133];
134
135/// Catalog of every `:command` verb with a short description. Drives
136/// the suggestion popup that surfaces matches as the operator types
137/// (so they don't have to memorize the whole list). Aliases stay
138/// implicit — they still work when typed but only the primary name
139/// shows up in the popup, to keep the list tidy.
140///
141/// Order matters: this is the order operators see, so screen jumps
142/// come first (most-used), action verbs in approximate frequency
143/// order, the four economics previews + buy-suggest grouped together,
144/// utility verbs last.
145const KNOWN_COMMANDS: &[(&str, &str)] = &[
146    ("health", "S1 Health screen"),
147    ("stamps", "S2 Stamps screen"),
148    ("swap", "S3 SWAP / cheques screen"),
149    ("lottery", "S4 Lottery + rchash"),
150    ("peers", "S6 Peers + bin saturation"),
151    ("network", "S7 Network / NAT"),
152    ("warmup", "S5 Warmup checklist"),
153    ("api", "S8 RPC / API health"),
154    ("tags", "S9 Tags / uploads"),
155    ("pins", "S11 Pins screen"),
156    ("topup-preview", "<batch> <amount-plur> — predict topup"),
157    ("dilute-preview", "<batch> <new-depth> — predict dilute"),
158    ("extend-preview", "<batch> <duration> — predict extend"),
159    ("buy-preview", "<depth> <amount-plur> — predict fresh buy"),
160    ("buy-suggest", "<size> <duration> — minimum (depth, amount)"),
161    (
162        "probe-upload",
163        "<batch> — single 4 KiB chunk, end-to-end probe",
164    ),
165    ("diagnose", "Export full snapshot to a file"),
166    ("pins-check", "Bulk integrity walk to a file"),
167    ("loggers", "Dump live logger registry"),
168    ("set-logger", "<expr> <level> — change a logger's verbosity"),
169    ("context", "<name> — switch node profile"),
170    ("quit", "Exit the cockpit"),
171];
172
173/// Produce the filtered list of (name, description) pairs that match
174/// the buffer's first whitespace token (case-insensitive prefix). An
175/// empty buffer matches everything. Pure for testability.
176fn filter_command_suggestions<'a>(
177    buffer: &str,
178    catalog: &'a [(&'a str, &'a str)],
179) -> Vec<&'a (&'a str, &'a str)> {
180    let head = buffer
181        .split_whitespace()
182        .next()
183        .unwrap_or("")
184        .to_ascii_lowercase();
185    catalog
186        .iter()
187        .filter(|(name, _)| name.starts_with(&head))
188        .collect()
189}
190
191#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
192pub enum Mode {
193    #[default]
194    Home,
195}
196
197/// Configuration knobs the binary passes into [`App::with_overrides`].
198/// Bundled in a struct so future flags don't churn the call site.
199#[derive(Debug, Default)]
200pub struct AppOverrides {
201    /// Force ASCII glyphs.
202    pub ascii: bool,
203    /// Force the mono palette.
204    pub no_color: bool,
205    /// `--bee-bin` CLI override.
206    pub bee_bin: Option<PathBuf>,
207    /// `--bee-config` CLI override.
208    pub bee_config: Option<PathBuf>,
209}
210
211/// Default timeout for waiting on `/health` after spawning Bee.
212/// Bee's first start can include chain-state catch-up; a generous
213/// budget here saves the operator from one false "didn't come up"
214/// alarm. Override later via config if needed.
215const BEE_API_READY_TIMEOUT: Duration = Duration::from_secs(60);
216
217impl App {
218    pub async fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
219        Self::with_overrides(tick_rate, frame_rate, AppOverrides::default()).await
220    }
221
222    /// Build an App with explicit `--ascii` / `--no-color` /
223    /// `--bee-bin` / `--bee-config` overrides. Async because, when
224    /// the bee paths are set, we spawn Bee and wait for its `/health`
225    /// before opening the TUI.
226    pub async fn with_overrides(
227        tick_rate: f64,
228        frame_rate: f64,
229        overrides: AppOverrides,
230    ) -> color_eyre::Result<Self> {
231        let (action_tx, action_rx) = mpsc::unbounded_channel();
232        let (cmd_status_tx, cmd_status_rx) = mpsc::unbounded_channel();
233        let config = Config::new()?;
234        // Install the theme first so any tracing emitted during the
235        // rest of `new` already reflects the operator's choice.
236        let force_no_color = overrides.no_color || theme::no_color_env();
237        theme::install_with_overrides(&config.ui, force_no_color, overrides.ascii);
238
239        // Pick the active node profile (and its URL) before spawning
240        // Bee — the supervisor's /health probe needs the URL.
241        let node = config
242            .active_node()
243            .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
244        let api = Arc::new(ApiClient::from_node(node)?);
245
246        // Resolve the bee paths: CLI flags > [bee] config block > unset.
247        let bee_bin = overrides
248            .bee_bin
249            .or_else(|| config.bee.as_ref().map(|b| b.bin.clone()));
250        let bee_config = overrides
251            .bee_config
252            .or_else(|| config.bee.as_ref().map(|b| b.config.clone()));
253        // [bee.logs] sub-config; defaults if [bee] is set but
254        // [bee.logs] isn't.
255        let bee_logs = config
256            .bee
257            .as_ref()
258            .map(|b| b.logs.clone())
259            .unwrap_or_default();
260        let supervisor = match (bee_bin, bee_config) {
261            (Some(bin), Some(cfg)) => {
262                eprintln!("bee-tui: spawning bee {bin:?} --config {cfg:?}");
263                let mut sup = BeeSupervisor::spawn(&bin, &cfg, bee_logs)?;
264                eprintln!(
265                    "bee-tui: log → {} (will appear in the cockpit's bottom pane)",
266                    sup.log_path().display()
267                );
268                eprintln!(
269                    "bee-tui: waiting for {} to respond on /health (up to {:?})...",
270                    api.url, BEE_API_READY_TIMEOUT
271                );
272                sup.wait_for_api(&api.url, BEE_API_READY_TIMEOUT).await?;
273                eprintln!("bee-tui: bee ready, opening cockpit");
274                Some(sup)
275            }
276            (Some(_), None) | (None, Some(_)) => {
277                return Err(eyre!(
278                    "[bee].bin and [bee].config must both be set (or both unset). \
279                     Use --bee-bin AND --bee-config, or both fields in config.toml."
280                ));
281            }
282            (None, None) => None,
283        };
284
285        // Spawn the watch / informer hub. Pollers attach to children
286        // of `root_cancel`, so quitting cancels everything in one go.
287        // The cadence preset comes from `[ui].refresh` — operators
288        // who want the original 2 s health stream can opt into
289        // `"live"`; the default is "calmer" (4 s health, 10 s
290        // topology).
291        let refresh = RefreshProfile::from_config(&config.ui.refresh);
292        let root_cancel = CancellationToken::new();
293        let watch = BeeWatch::start_with_profile(api.clone(), &root_cancel, refresh);
294        let health_rx = watch.health();
295
296        let screens = build_screens(&api, &watch);
297        // Bottom log pane subscribes to the bee::http capture set up
298        // by logging::init for its `bee::http` tab. The four severity
299        // tabs + "Bee HTTP" tab populate from the supervisor's log
300        // tail (increment 3+); for now they show placeholders.
301        let (persisted, state_path) = State::load();
302        let initial_tab = LogTab::from_kebab(&persisted.log_pane_active_tab);
303        let mut log_pane = LogPane::new(
304            log_capture::handle(),
305            initial_tab,
306            persisted.log_pane_height,
307        );
308        log_pane.set_spawn_active(supervisor.is_some());
309
310        // Spawn the bee-log tailer if we own the supervisor. The
311        // tailer parses each new line of the captured Bee log and
312        // forwards `(LogTab, BeeLogLine)` pairs down an mpsc the
313        // App drains every Tick. Inherits root_cancel so quit
314        // unwinds it the same way as every other spawned task.
315        let bee_log_rx = supervisor.as_ref().map(|sup| {
316            let (tx, rx) = mpsc::unbounded_channel();
317            crate::bee_log_tailer::spawn(
318                sup.log_path().to_path_buf(),
319                tx,
320                root_cancel.child_token(),
321            );
322            rx
323        });
324
325        // Optional Prometheus `/metrics` endpoint. Off by default;
326        // when `[metrics].enabled = true` we spawn the server under
327        // `root_cancel` so it dies with the cockpit. Failures here
328        // are non-fatal — surface a tracing error and keep going,
329        // since a port-conflict shouldn't block the operator from
330        // using the cockpit itself.
331        if config.metrics.enabled {
332            match config.metrics.addr.parse::<std::net::SocketAddr>() {
333                Ok(bind_addr) => {
334                    let render_fn = build_metrics_render_fn(watch.clone(), log_capture::handle());
335                    let cancel = root_cancel.child_token();
336                    match crate::metrics_server::spawn(bind_addr, render_fn, cancel).await {
337                        Ok(actual) => {
338                            eprintln!(
339                                "bee-tui: metrics endpoint serving /metrics on http://{actual}"
340                            );
341                        }
342                        Err(e) => {
343                            tracing::error!(
344                                "metrics: failed to start endpoint on {bind_addr}: {e}"
345                            );
346                        }
347                    }
348                }
349                Err(e) => {
350                    tracing::error!(
351                        "metrics: invalid [metrics].addr {:?}: {e}",
352                        config.metrics.addr
353                    );
354                }
355            }
356        }
357
358        Ok(Self {
359            tick_rate,
360            frame_rate,
361            screens,
362            current_screen: 0,
363            log_pane,
364            state_path,
365            should_quit: false,
366            should_suspend: false,
367            config,
368            mode: Mode::Home,
369            last_tick_key_events: Vec::new(),
370            action_tx,
371            action_rx,
372            root_cancel,
373            api,
374            watch,
375            health_rx,
376            command_buffer: None,
377            command_suggestion_index: 0,
378            command_status: None,
379            help_visible: false,
380            quit_pending: None,
381            supervisor,
382            bee_status: BeeStatus::Running,
383            bee_log_rx,
384            cmd_status_tx,
385            cmd_status_rx,
386        })
387    }
388
389    pub async fn run(&mut self) -> color_eyre::Result<()> {
390        let mut tui = Tui::new()?
391            // .mouse(true) // uncomment this line to enable mouse support
392            .tick_rate(self.tick_rate)
393            .frame_rate(self.frame_rate);
394        tui.enter()?;
395
396        let tx = self.action_tx.clone();
397        let cfg = self.config.clone();
398        let size = tui.size()?;
399        for component in self.iter_components_mut() {
400            component.register_action_handler(tx.clone())?;
401            component.register_config_handler(cfg.clone())?;
402            component.init(size)?;
403        }
404
405        let action_tx = self.action_tx.clone();
406        loop {
407            self.handle_events(&mut tui).await?;
408            self.handle_actions(&mut tui)?;
409            if self.should_suspend {
410                tui.suspend()?;
411                action_tx.send(Action::Resume)?;
412                action_tx.send(Action::ClearScreen)?;
413                // tui.mouse(true);
414                tui.enter()?;
415            } else if self.should_quit {
416                tui.stop()?;
417                break;
418            }
419        }
420        // Unwind every spawned task before tearing down the terminal.
421        self.watch.shutdown();
422        self.root_cancel.cancel();
423        // Persist UI state (last tab + height) so the next launch
424        // restores the operator's preference. Best-effort — failures
425        // log a warning but never block quit.
426        let snapshot = State {
427            log_pane_height: self.log_pane.height(),
428            log_pane_active_tab: self.log_pane.active_tab().to_kebab().to_string(),
429        };
430        snapshot.save(&self.state_path);
431        // SIGTERM Bee (pgroup) and wait for clean exit. Done before
432        // tui.exit() so any "bee shutting down" messages still land
433        // in the supervisor's log file (no race with terminal teardown).
434        if let Some(sup) = self.supervisor.take() {
435            let final_status = sup.shutdown_default().await;
436            tracing::info!("bee child exited: {}", final_status.label());
437        }
438        tui.exit()?;
439        Ok(())
440    }
441
442    async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
443        let Some(event) = tui.next_event().await else {
444            return Ok(());
445        };
446        let action_tx = self.action_tx.clone();
447        // Sample modal state both before and after handling: a key
448        // that *opens* a modal (`?` → help) only flips state inside
449        // handle, but the same key shouldn't propagate to screens;
450        // a key that *closes* one (Esc on help) flips it the other
451        // way but also shouldn't propagate. Either side of the
452        // transition counts as "modal" for swallowing purposes.
453        let modal_before = self.command_buffer.is_some() || self.help_visible;
454        match event {
455            Event::Quit => action_tx.send(Action::Quit)?,
456            Event::Tick => action_tx.send(Action::Tick)?,
457            Event::Render => action_tx.send(Action::Render)?,
458            Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
459            Event::Key(key) => self.handle_key_event(key)?,
460            _ => {}
461        }
462        let modal_after = self.command_buffer.is_some() || self.help_visible;
463        // Non-key events (Tick / Resize / Render) always propagate
464        // so screens keep refreshing under modals.
465        let propagate = !((modal_before || modal_after) && matches!(event, Event::Key(_)));
466        if propagate {
467            for component in self.iter_components_mut() {
468                if let Some(action) = component.handle_events(Some(event.clone()))? {
469                    action_tx.send(action)?;
470                }
471            }
472        }
473        Ok(())
474    }
475
476    /// Iterate every component (screens + log pane) for uniform
477    /// lifecycle ticks. Returns trait objects so the heterogeneous
478    /// `LogPane` (a concrete type for direct method access in the
479    /// app layer) walks alongside the boxed screens.
480    fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut dyn Component> {
481        self.screens
482            .iter_mut()
483            .map(|c| c.as_mut() as &mut dyn Component)
484            .chain(std::iter::once(&mut self.log_pane as &mut dyn Component))
485    }
486
487    fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
488        // While a `:command` is being typed every key edits the
489        // buffer or commits / cancels the line. No other keymap
490        // applies.
491        if self.command_buffer.is_some() {
492            self.handle_command_mode_key(key)?;
493            return Ok(());
494        }
495        // While the `?` help overlay is up, only Esc / ? / q close
496        // it. Don't propagate to components or process other keys
497        // — the operator is reading reference, not driving.
498        if self.help_visible {
499            match key.code {
500                crossterm::event::KeyCode::Esc
501                | crossterm::event::KeyCode::Char('?')
502                | crossterm::event::KeyCode::Char('q') => {
503                    self.help_visible = false;
504                }
505                _ => {}
506            }
507            return Ok(());
508        }
509        // `?` opens the help overlay. We capture it at the app level
510        // so every screen gets the overlay for free without each one
511        // having to wire its own.
512        if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
513            self.help_visible = true;
514            return Ok(());
515        }
516        let action_tx = self.action_tx.clone();
517        // ':' opens the command bar.
518        if matches!(key.code, crossterm::event::KeyCode::Char(':')) {
519            self.command_buffer = Some(String::new());
520            self.command_status = None;
521            return Ok(());
522        }
523        // Tab / Shift+Tab keep working as a quick screen-cycle
524        // shortcut even after the `:command` bar lands. crossterm
525        // surfaces Shift+Tab as `BackTab` (a separate KeyCode rather
526        // than Tab + the Shift modifier), so both branches are needed.
527        if matches!(key.code, crossterm::event::KeyCode::Tab) {
528            if !self.screens.is_empty() {
529                self.current_screen = (self.current_screen + 1) % self.screens.len();
530                debug!(
531                    "switched to screen {}",
532                    SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
533                );
534            }
535            return Ok(());
536        }
537        if matches!(key.code, crossterm::event::KeyCode::BackTab) {
538            if !self.screens.is_empty() {
539                let len = self.screens.len();
540                self.current_screen = (self.current_screen + len - 1) % len;
541                debug!(
542                    "switched to screen {}",
543                    SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
544                );
545            }
546            return Ok(());
547        }
548        // Log-pane controls. `[` / `]` cycle tabs (lazygit / k9s
549        // pattern, no conflict with screen-cycling Tab/Shift+Tab).
550        // `+` / `-` resize the pane in 1-line steps, clamped to
551        // [LOG_PANE_MIN_HEIGHT, LOG_PANE_MAX_HEIGHT]. The state is
552        // persisted on quit.
553        if matches!(key.code, crossterm::event::KeyCode::Char('['))
554            && key.modifiers == crossterm::event::KeyModifiers::NONE
555        {
556            self.log_pane.prev_tab();
557            return Ok(());
558        }
559        if matches!(key.code, crossterm::event::KeyCode::Char(']'))
560            && key.modifiers == crossterm::event::KeyModifiers::NONE
561        {
562            self.log_pane.next_tab();
563            return Ok(());
564        }
565        if matches!(key.code, crossterm::event::KeyCode::Char('+'))
566            && key.modifiers == crossterm::event::KeyModifiers::NONE
567        {
568            self.log_pane.grow();
569            return Ok(());
570        }
571        if matches!(key.code, crossterm::event::KeyCode::Char('-'))
572            && key.modifiers == crossterm::event::KeyModifiers::NONE
573        {
574            self.log_pane.shrink();
575            return Ok(());
576        }
577        // Log-pane scroll. Shift+Up/Down step one line; Shift+PgUp/PgDn
578        // step ten; Shift+End resumes tail. The Shift modifier
579        // distinguishes from in-screen scroll (j/k/PgUp/PgDn) bound
580        // by S2/S6/S9 — those keep working without conflict.
581        if key.modifiers == crossterm::event::KeyModifiers::SHIFT {
582            match key.code {
583                crossterm::event::KeyCode::Up => {
584                    self.log_pane.scroll_up(1);
585                    return Ok(());
586                }
587                crossterm::event::KeyCode::Down => {
588                    self.log_pane.scroll_down(1);
589                    return Ok(());
590                }
591                crossterm::event::KeyCode::PageUp => {
592                    self.log_pane.scroll_up(10);
593                    return Ok(());
594                }
595                crossterm::event::KeyCode::PageDown => {
596                    self.log_pane.scroll_down(10);
597                    return Ok(());
598                }
599                crossterm::event::KeyCode::End => {
600                    self.log_pane.resume_tail();
601                    return Ok(());
602                }
603                // Horizontal pan for long Bee log lines. 8 chars per
604                // keystroke feels live without making the operator
605                // hold the key; `Shift+End` resets both axes via
606                // resume_tail() so there's no separate "back to
607                // left edge" binding.
608                crossterm::event::KeyCode::Left => {
609                    self.log_pane.scroll_left(8);
610                    return Ok(());
611                }
612                crossterm::event::KeyCode::Right => {
613                    self.log_pane.scroll_right(8);
614                    return Ok(());
615                }
616                _ => {}
617            }
618        }
619        // `q` is the easy-to-misclick exit. Require a double-tap
620        // within `QUIT_CONFIRM_WINDOW` so a stray keystroke doesn't
621        // kill an active monitoring session. `Ctrl+C` / `Ctrl+D`
622        // remain wired through the keybindings system as immediate
623        // quit — escape hatches if the cockpit ever stops responding.
624        if matches!(key.code, crossterm::event::KeyCode::Char('q'))
625            && key.modifiers == crossterm::event::KeyModifiers::NONE
626        {
627            match resolve_quit_press(self.quit_pending, Instant::now(), QUIT_CONFIRM_WINDOW) {
628                QuitResolution::Confirm => {
629                    self.quit_pending = None;
630                    self.action_tx.send(Action::Quit)?;
631                }
632                QuitResolution::Pending => {
633                    self.quit_pending = Some(Instant::now());
634                    self.command_status = Some(CommandStatus::Info(
635                        "press q again to quit (Esc cancels)".into(),
636                    ));
637                }
638            }
639            return Ok(());
640        }
641        // Any other key resets the pending-quit window so the operator
642        // doesn't accidentally confirm later from a forgotten first
643        // tap.
644        if self.quit_pending.is_some() {
645            self.quit_pending = None;
646        }
647        let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
648            return Ok(());
649        };
650        match keymap.get(&vec![key]) {
651            Some(action) => {
652                info!("Got action: {action:?}");
653                action_tx.send(action.clone())?;
654            }
655            _ => {
656                // If the key was not handled as a single key action,
657                // then consider it for multi-key combinations.
658                self.last_tick_key_events.push(key);
659
660                // Check for multi-key combinations
661                if let Some(action) = keymap.get(&self.last_tick_key_events) {
662                    info!("Got action: {action:?}");
663                    action_tx.send(action.clone())?;
664                }
665            }
666        }
667        Ok(())
668    }
669
670    fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
671        use crossterm::event::KeyCode;
672        let buf = match self.command_buffer.as_mut() {
673            Some(b) => b,
674            None => return Ok(()),
675        };
676        match key.code {
677            KeyCode::Esc => {
678                // Cancel without dispatching.
679                self.command_buffer = None;
680                self.command_suggestion_index = 0;
681            }
682            KeyCode::Enter => {
683                let line = std::mem::take(buf);
684                self.command_buffer = None;
685                self.command_suggestion_index = 0;
686                self.execute_command(&line)?;
687            }
688            KeyCode::Up => {
689                // Walk up the filtered suggestion list. Saturates at
690                // 0 so a stray Up doesn't wrap unexpectedly.
691                self.command_suggestion_index = self.command_suggestion_index.saturating_sub(1);
692            }
693            KeyCode::Down => {
694                let n = filter_command_suggestions(buf, KNOWN_COMMANDS).len();
695                if n > 0 && self.command_suggestion_index + 1 < n {
696                    self.command_suggestion_index += 1;
697                }
698            }
699            KeyCode::Tab => {
700                // Autocomplete: replace the buffer's first token with
701                // the highlighted suggestion's name and append a
702                // space so the operator can type args immediately.
703                let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
704                if let Some((name, _)) = matches.get(self.command_suggestion_index) {
705                    let rest = buf
706                        .split_once(char::is_whitespace)
707                        .map(|(_, tail)| tail)
708                        .unwrap_or("");
709                    let new = if rest.is_empty() {
710                        format!("{name} ")
711                    } else {
712                        format!("{name} {rest}")
713                    };
714                    buf.clear();
715                    buf.push_str(&new);
716                    self.command_suggestion_index = 0;
717                }
718            }
719            KeyCode::Backspace => {
720                buf.pop();
721                self.command_suggestion_index = 0;
722            }
723            KeyCode::Char(c) => {
724                buf.push(c);
725                self.command_suggestion_index = 0;
726            }
727            _ => {}
728        }
729        Ok(())
730    }
731
732    /// Resolve a `:command` token to the action it represents.
733    /// Empty input is a silent no-op (operator typed `:` then Enter).
734    fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
735        let trimmed = line.trim();
736        if trimmed.is_empty() {
737            return Ok(());
738        }
739        let head = trimmed.split_whitespace().next().unwrap_or("");
740        match head {
741            "q" | "quit" => {
742                self.action_tx.send(Action::Quit)?;
743                self.command_status = Some(CommandStatus::Info("quitting".into()));
744            }
745            "diagnose" | "diag" => {
746                self.command_status = Some(match self.export_diagnostic_bundle() {
747                    Ok(path) => CommandStatus::Info(format!(
748                        "diagnostic bundle exported to {}",
749                        path.display()
750                    )),
751                    Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
752                });
753            }
754            "pins-check" => {
755                // `:pins-check` keeps the legacy bulk-check-to-file behaviour;
756                // `:pins` (without `-check`) now jumps to the S11 screen via
757                // the screen-name catch-all below. The two are deliberately
758                // distinct so an operator who types `:pins` doesn't kick off
759                // a many-minute integrity walk by accident.
760                self.command_status = Some(match self.start_pins_check() {
761                    Ok(path) => CommandStatus::Info(format!(
762                        "pins integrity check running → {} (tail to watch progress)",
763                        path.display()
764                    )),
765                    Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
766                });
767            }
768            "loggers" => {
769                self.command_status = Some(match self.start_loggers_dump() {
770                    Ok(path) => CommandStatus::Info(format!(
771                        "loggers snapshot writing → {} (open when ready)",
772                        path.display()
773                    )),
774                    Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
775                });
776            }
777            "set-logger" => {
778                let mut parts = trimmed.split_whitespace();
779                let _ = parts.next(); // command head
780                let expr = parts.next().unwrap_or("");
781                let level = parts.next().unwrap_or("");
782                if expr.is_empty() || level.is_empty() {
783                    self.command_status = Some(CommandStatus::Err(
784                        "usage: :set-logger <expr> <level>  (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
785                            .into(),
786                    ));
787                    return Ok(());
788                }
789                self.start_set_logger(expr.to_string(), level.to_string());
790                self.command_status = Some(CommandStatus::Info(format!(
791                    "set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
792                )));
793            }
794            "topup-preview" => {
795                self.command_status = Some(self.run_topup_preview(trimmed));
796            }
797            "dilute-preview" => {
798                self.command_status = Some(self.run_dilute_preview(trimmed));
799            }
800            "extend-preview" => {
801                self.command_status = Some(self.run_extend_preview(trimmed));
802            }
803            "buy-preview" => {
804                self.command_status = Some(self.run_buy_preview(trimmed));
805            }
806            "buy-suggest" => {
807                self.command_status = Some(self.run_buy_suggest(trimmed));
808            }
809            "probe-upload" => {
810                self.command_status = Some(self.run_probe_upload(trimmed));
811            }
812            "context" | "ctx" => {
813                let target = trimmed.split_whitespace().nth(1).unwrap_or("");
814                if target.is_empty() {
815                    let known: Vec<String> =
816                        self.config.nodes.iter().map(|n| n.name.clone()).collect();
817                    self.command_status = Some(CommandStatus::Err(format!(
818                        "usage: :context <name>  (known: {})",
819                        known.join(", ")
820                    )));
821                    return Ok(());
822                }
823                self.command_status = Some(match self.switch_context(target) {
824                    Ok(()) => CommandStatus::Info(format!(
825                        "switched to context {target} ({})",
826                        self.api.url
827                    )),
828                    Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
829                });
830            }
831            screen
832                if SCREEN_NAMES
833                    .iter()
834                    .any(|name| name.eq_ignore_ascii_case(screen)) =>
835            {
836                if let Some(idx) = SCREEN_NAMES
837                    .iter()
838                    .position(|name| name.eq_ignore_ascii_case(screen))
839                {
840                    self.current_screen = idx;
841                    self.command_status =
842                        Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
843                }
844            }
845            other => {
846                self.command_status = Some(CommandStatus::Err(format!(
847                    "unknown command: {other:?} (try :health, :stamps, :swap, :lottery, :peers, :network, :warmup, :api, :tags, :pins, :diagnose, :pins-check, :loggers, :set-logger, :topup-preview, :dilute-preview, :extend-preview, :buy-preview, :buy-suggest, :probe-upload, :context, :quit)"
848                )));
849            }
850        }
851        Ok(())
852    }
853
854    /// Read-only "what would happen if I topped up batch X with N
855    /// PLUR/chunk?". Pure math — no Bee calls, no writes. Args:
856    /// `:topup-preview <batch-prefix> <amount-plur>`.
857    fn run_topup_preview(&self, line: &str) -> CommandStatus {
858        let parts: Vec<&str> = line.split_whitespace().collect();
859        let (prefix, amount_str) = match parts.as_slice() {
860            [_, prefix, amount, ..] => (*prefix, *amount),
861            _ => {
862                return CommandStatus::Err(
863                    "usage: :topup-preview <batch-prefix> <amount-plur-per-chunk>".into(),
864                );
865            }
866        };
867        let chain = match self.health_rx.borrow().chain_state.clone() {
868            Some(c) => c,
869            None => return CommandStatus::Err("chain state not loaded yet".into()),
870        };
871        let stamps = self.watch.stamps().borrow().clone();
872        let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
873            Ok(b) => b.clone(),
874            Err(e) => return CommandStatus::Err(e),
875        };
876        let amount = match stamp_preview::parse_plur_amount(amount_str) {
877            Ok(a) => a,
878            Err(e) => return CommandStatus::Err(e),
879        };
880        match stamp_preview::topup_preview(&batch, amount, &chain) {
881            Ok(p) => CommandStatus::Info(p.summary()),
882            Err(e) => CommandStatus::Err(e),
883        }
884    }
885
886    /// `:dilute-preview <batch-prefix> <new-depth>` — pure math:
887    /// halves per-chunk amount and TTL for each +1 in depth, doubles
888    /// theoretical capacity.
889    fn run_dilute_preview(&self, line: &str) -> CommandStatus {
890        let parts: Vec<&str> = line.split_whitespace().collect();
891        let (prefix, depth_str) = match parts.as_slice() {
892            [_, prefix, depth, ..] => (*prefix, *depth),
893            _ => {
894                return CommandStatus::Err(
895                    "usage: :dilute-preview <batch-prefix> <new-depth>".into(),
896                );
897            }
898        };
899        let new_depth: u8 = match depth_str.parse() {
900            Ok(d) => d,
901            Err(_) => {
902                return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
903            }
904        };
905        let stamps = self.watch.stamps().borrow().clone();
906        let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
907            Ok(b) => b.clone(),
908            Err(e) => return CommandStatus::Err(e),
909        };
910        match stamp_preview::dilute_preview(&batch, new_depth) {
911            Ok(p) => CommandStatus::Info(p.summary()),
912            Err(e) => CommandStatus::Err(e),
913        }
914    }
915
916    /// `:extend-preview <batch-prefix> <duration>` — accepts `30d`,
917    /// `12h`, `90m`, `45s`, or plain seconds.
918    fn run_extend_preview(&self, line: &str) -> CommandStatus {
919        let parts: Vec<&str> = line.split_whitespace().collect();
920        let (prefix, duration_str) = match parts.as_slice() {
921            [_, prefix, duration, ..] => (*prefix, *duration),
922            _ => {
923                return CommandStatus::Err(
924                    "usage: :extend-preview <batch-prefix> <duration>  (e.g. 30d, 12h, 90m, 45s, or plain seconds)".into(),
925                );
926            }
927        };
928        let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
929            Ok(s) => s,
930            Err(e) => return CommandStatus::Err(e),
931        };
932        let chain = match self.health_rx.borrow().chain_state.clone() {
933            Some(c) => c,
934            None => return CommandStatus::Err("chain state not loaded yet".into()),
935        };
936        let stamps = self.watch.stamps().borrow().clone();
937        let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
938            Ok(b) => b.clone(),
939            Err(e) => return CommandStatus::Err(e),
940        };
941        match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
942            Ok(p) => CommandStatus::Info(p.summary()),
943            Err(e) => CommandStatus::Err(e),
944        }
945    }
946
947    /// `:probe-upload <batch-prefix>` — uploads one synthetic 4 KiB
948    /// chunk to Bee and reports end-to-end latency. The cockpit is
949    /// otherwise read-only; this is the deliberate exception. The
950    /// chunk's payload is timestamp-randomised so each invocation
951    /// fully exercises the upload + stamp path (no Bee dedup).
952    ///
953    /// Cost: one bucket increment on the chosen batch + the BZZ for
954    /// one stamped chunk (`current_price` PLUR, fractions of a cent
955    /// at typical prices). Returns immediately with a "started"
956    /// notice; the actual outcome lands on the command bar via the
957    /// async `cmd_status_tx` channel when Bee responds.
958    fn run_probe_upload(&self, line: &str) -> CommandStatus {
959        let parts: Vec<&str> = line.split_whitespace().collect();
960        let prefix = match parts.as_slice() {
961            [_, prefix, ..] => *prefix,
962            _ => {
963                return CommandStatus::Err(
964                    "usage: :probe-upload <batch-prefix>  (uploads one synthetic 4 KiB chunk)"
965                        .into(),
966                );
967            }
968        };
969        let stamps = self.watch.stamps().borrow().clone();
970        let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
971            Ok(b) => b.clone(),
972            Err(e) => return CommandStatus::Err(e),
973        };
974        if !batch.usable {
975            return CommandStatus::Err(format!(
976                "batch {} is not usable yet (waiting on chain confirmation) — pick another",
977                short_hex(&batch.batch_id.to_hex(), 8),
978            ));
979        }
980        if batch.batch_ttl <= 0 {
981            return CommandStatus::Err(format!(
982                "batch {} is expired — pick another",
983                short_hex(&batch.batch_id.to_hex(), 8),
984            ));
985        }
986
987        let api = self.api.clone();
988        let tx = self.cmd_status_tx.clone();
989        let batch_id = batch.batch_id;
990        let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
991        let task_short = batch_short.clone();
992        tokio::spawn(async move {
993            let chunk = build_synthetic_probe_chunk();
994            let started = Instant::now();
995            let result = api.bee().file().upload_chunk(&batch_id, chunk, None).await;
996            let elapsed_ms = started.elapsed().as_millis();
997            let status = match result {
998                Ok(res) => CommandStatus::Info(format!(
999                    "probe-upload OK in {elapsed_ms}ms — batch {task_short}, ref {}",
1000                    short_hex(&res.reference.to_hex(), 8),
1001                )),
1002                Err(e) => CommandStatus::Err(format!(
1003                    "probe-upload FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1004                )),
1005            };
1006            let _ = tx.send(status);
1007        });
1008
1009        CommandStatus::Info(format!(
1010            "probe-upload to batch {batch_short} in flight — result will replace this line"
1011        ))
1012    }
1013
1014    /// `:buy-suggest <size> <duration>` — inverse of buy-preview.
1015    /// Operator says "I want X bytes for Y seconds", we return the
1016    /// minimum `(depth, amount)` that covers it. Depth rounds up
1017    /// to the next power of two so the headroom is operator-visible;
1018    /// duration rounds up in chain blocks.
1019    fn run_buy_suggest(&self, line: &str) -> CommandStatus {
1020        let parts: Vec<&str> = line.split_whitespace().collect();
1021        let (size_str, duration_str) = match parts.as_slice() {
1022            [_, size, duration, ..] => (*size, *duration),
1023            _ => {
1024                return CommandStatus::Err(
1025                    "usage: :buy-suggest <size> <duration>  (e.g. 5GiB 30d, 100MiB 12h)".into(),
1026                );
1027            }
1028        };
1029        let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
1030            Ok(b) => b,
1031            Err(e) => return CommandStatus::Err(e),
1032        };
1033        let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1034            Ok(s) => s,
1035            Err(e) => return CommandStatus::Err(e),
1036        };
1037        let chain = match self.health_rx.borrow().chain_state.clone() {
1038            Some(c) => c,
1039            None => return CommandStatus::Err("chain state not loaded yet".into()),
1040        };
1041        match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
1042            Ok(s) => CommandStatus::Info(s.summary()),
1043            Err(e) => CommandStatus::Err(e),
1044        }
1045    }
1046
1047    /// `:buy-preview <depth> <amount-plur>` — hypothetical fresh
1048    /// batch; no batch lookup needed.
1049    fn run_buy_preview(&self, line: &str) -> CommandStatus {
1050        let parts: Vec<&str> = line.split_whitespace().collect();
1051        let (depth_str, amount_str) = match parts.as_slice() {
1052            [_, depth, amount, ..] => (*depth, *amount),
1053            _ => {
1054                return CommandStatus::Err(
1055                    "usage: :buy-preview <depth> <amount-plur-per-chunk>".into(),
1056                );
1057            }
1058        };
1059        let depth: u8 = match depth_str.parse() {
1060            Ok(d) => d,
1061            Err(_) => {
1062                return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1063            }
1064        };
1065        let amount = match stamp_preview::parse_plur_amount(amount_str) {
1066            Ok(a) => a,
1067            Err(e) => return CommandStatus::Err(e),
1068        };
1069        let chain = match self.health_rx.borrow().chain_state.clone() {
1070            Some(c) => c,
1071            None => return CommandStatus::Err("chain state not loaded yet".into()),
1072        };
1073        match stamp_preview::buy_preview(depth, amount, &chain) {
1074            Ok(p) => CommandStatus::Info(p.summary()),
1075            Err(e) => CommandStatus::Err(e),
1076        }
1077    }
1078
1079    /// Tear down the current watch hub and ApiClient, build a new
1080    /// connection against the named NodeConfig, and rebuild the
1081    /// screen list against fresh receivers. Component-internal state
1082    /// (Lottery's bench history, Network's reachability stability
1083    /// timer, etc.) is intentionally lost — a profile switch is a
1084    /// fresh slate, the same way it would be on app restart.
1085    fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
1086        let node = self
1087            .config
1088            .nodes
1089            .iter()
1090            .find(|n| n.name == target)
1091            .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
1092            .clone();
1093        let new_api = Arc::new(ApiClient::from_node(&node)?);
1094        // Cancel the current hub's children and let it drop. The new
1095        // hub spawns under the same root_cancel so quit-time teardown
1096        // still walks the whole tree in one go.
1097        self.watch.shutdown();
1098        let refresh = RefreshProfile::from_config(&self.config.ui.refresh);
1099        let new_watch = BeeWatch::start_with_profile(new_api.clone(), &self.root_cancel, refresh);
1100        let new_health_rx = new_watch.health();
1101        let new_screens = build_screens(&new_api, &new_watch);
1102        self.api = new_api;
1103        self.watch = new_watch;
1104        self.health_rx = new_health_rx;
1105        self.screens = new_screens;
1106        // Keep the same tab index so the operator stays on the
1107        // screen they were looking at — same data shape, new node.
1108        Ok(())
1109    }
1110
1111    /// Build and persist a redacted diagnostic bundle to a file in
1112    /// the system temp directory. Designed to be paste-ready into a
1113    /// support thread (Discord, GitHub issue) without leaking
1114    /// auth tokens — URLs are reduced to their path component, since
1115    /// Bearer tokens live in headers, not URLs.
1116    /// Kick off `GET /pins/check` in a background task. Returns the
1117    /// destination file path immediately so the operator can `tail -f`
1118    /// it while bee-rs streams the NDJSON response. Each pin is
1119    /// appended as a single line: `<ref>  total=N  missing=N  invalid=N
1120    /// (healthy|UNHEALTHY)`. A `# done. <n> pins checked.` trailer
1121    /// signals completion.
1122    ///
1123    /// The task captures `Arc<ApiClient>` so a `:context` switch
1124    /// mid-check still completes against the original profile — the
1125    /// destination file's name pins the profile so two parallel
1126    /// invocations against different profiles don't collide.
1127    fn start_pins_check(&self) -> std::io::Result<PathBuf> {
1128        let secs = SystemTime::now()
1129            .duration_since(UNIX_EPOCH)
1130            .map(|d| d.as_secs())
1131            .unwrap_or(0);
1132        let path = std::env::temp_dir().join(format!(
1133            "bee-tui-pins-check-{}-{secs}.txt",
1134            sanitize_for_filename(&self.api.name),
1135        ));
1136        // Pre-create with a header so the operator's `tail -f` finds
1137        // something immediately, even before the first pin lands.
1138        std::fs::write(
1139            &path,
1140            format!(
1141                "# bee-tui :pins-check\n# profile  {}\n# endpoint {}\n# started  {}\n",
1142                self.api.name,
1143                self.api.url,
1144                format_utc_now(),
1145            ),
1146        )?;
1147
1148        let api = self.api.clone();
1149        let dest = path.clone();
1150        tokio::spawn(async move {
1151            let bee = api.bee();
1152            match bee.api().check_pins(None).await {
1153                Ok(entries) => {
1154                    let mut body = String::new();
1155                    for e in &entries {
1156                        body.push_str(&format!(
1157                            "{}  total={}  missing={}  invalid={}  {}\n",
1158                            e.reference.to_hex(),
1159                            e.total,
1160                            e.missing,
1161                            e.invalid,
1162                            if e.is_healthy() {
1163                                "healthy"
1164                            } else {
1165                                "UNHEALTHY"
1166                            },
1167                        ));
1168                    }
1169                    body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
1170                    if let Err(e) = append(&dest, &body) {
1171                        let _ = append(&dest, &format!("# write error: {e}\n"));
1172                    }
1173                }
1174                Err(e) => {
1175                    let _ = append(&dest, &format!("# error: {e}\n"));
1176                }
1177            }
1178        });
1179        Ok(path)
1180    }
1181
1182    /// Spawn a fire-and-forget task that calls
1183    /// `set_logger(expression, level)` against the node. The result
1184    /// (success or error) is appended to a `:loggers`-style log file
1185    /// so the operator has a paper trail of mutations made from the
1186    /// cockpit. Per-profile and per-call so multiple `:set-logger`
1187    /// invocations don't trample each other's record.
1188    ///
1189    /// Bee will validate `level` against its own enum (`none|error|
1190    /// warning|info|debug|all`); bee-rs does the same client-side, so
1191    /// a mistyped level errors out before any HTTP request goes out.
1192    fn start_set_logger(&self, expression: String, level: String) {
1193        let secs = SystemTime::now()
1194            .duration_since(UNIX_EPOCH)
1195            .map(|d| d.as_secs())
1196            .unwrap_or(0);
1197        let dest = std::env::temp_dir().join(format!(
1198            "bee-tui-set-logger-{}-{secs}.txt",
1199            sanitize_for_filename(&self.api.name),
1200        ));
1201        let _ = std::fs::write(
1202            &dest,
1203            format!(
1204                "# bee-tui :set-logger\n# profile  {}\n# endpoint {}\n# expr     {expression}\n# level    {level}\n# started  {}\n",
1205                self.api.name,
1206                self.api.url,
1207                format_utc_now(),
1208            ),
1209        );
1210
1211        let api = self.api.clone();
1212        tokio::spawn(async move {
1213            let bee = api.bee();
1214            match bee.debug().set_logger(&expression, &level).await {
1215                Ok(()) => {
1216                    let _ = append(
1217                        &dest,
1218                        &format!("# done. {expression} → {level} accepted by Bee.\n"),
1219                    );
1220                }
1221                Err(e) => {
1222                    let _ = append(&dest, &format!("# error: {e}\n"));
1223                }
1224            }
1225        });
1226    }
1227
1228    /// Snapshot Bee's logger configuration to a file. Same on-demand
1229    /// pattern as `:pins-check`: capture the registered loggers + their
1230    /// verbosity into a sortable text table so operators can answer
1231    /// "is push-sync at debug right now?" without curling the API.
1232    fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
1233        let secs = SystemTime::now()
1234            .duration_since(UNIX_EPOCH)
1235            .map(|d| d.as_secs())
1236            .unwrap_or(0);
1237        let path = std::env::temp_dir().join(format!(
1238            "bee-tui-loggers-{}-{secs}.txt",
1239            sanitize_for_filename(&self.api.name),
1240        ));
1241        std::fs::write(
1242            &path,
1243            format!(
1244                "# bee-tui :loggers\n# profile  {}\n# endpoint {}\n# started  {}\n",
1245                self.api.name,
1246                self.api.url,
1247                format_utc_now(),
1248            ),
1249        )?;
1250
1251        let api = self.api.clone();
1252        let dest = path.clone();
1253        tokio::spawn(async move {
1254            let bee = api.bee();
1255            match bee.debug().loggers().await {
1256                Ok(listing) => {
1257                    let mut rows = listing.loggers.clone();
1258                    // Stable sort: verbosity buckets first ("all"
1259                    // before "1"/"info" etc. so the loud loggers
1260                    // float to the top), then logger name.
1261                    rows.sort_by(|a, b| {
1262                        verbosity_rank(&b.verbosity)
1263                            .cmp(&verbosity_rank(&a.verbosity))
1264                            .then_with(|| a.logger.cmp(&b.logger))
1265                    });
1266                    let mut body = String::new();
1267                    body.push_str(&format!("# {} loggers registered\n", rows.len()));
1268                    body.push_str("# VERBOSITY  LOGGER\n");
1269                    for r in &rows {
1270                        body.push_str(&format!("  {:<9}  {}\n", r.verbosity, r.logger,));
1271                    }
1272                    body.push_str("# done.\n");
1273                    if let Err(e) = append(&dest, &body) {
1274                        let _ = append(&dest, &format!("# write error: {e}\n"));
1275                    }
1276                }
1277                Err(e) => {
1278                    let _ = append(&dest, &format!("# error: {e}\n"));
1279                }
1280            }
1281        });
1282        Ok(path)
1283    }
1284
1285    fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
1286        let bundle = self.render_diagnostic_bundle();
1287        let secs = SystemTime::now()
1288            .duration_since(UNIX_EPOCH)
1289            .map(|d| d.as_secs())
1290            .unwrap_or(0);
1291        let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
1292        std::fs::write(&path, bundle)?;
1293        Ok(path)
1294    }
1295
1296    fn render_diagnostic_bundle(&self) -> String {
1297        let now = format_utc_now();
1298        let health = self.health_rx.borrow().clone();
1299        let topology = self.watch.topology().borrow().clone();
1300        let gates = Health::gates_for(&health, Some(&topology));
1301        let recent: Vec<_> = log_capture::handle()
1302            .map(|c| {
1303                let mut snap = c.snapshot();
1304                let len = snap.len();
1305                if len > 50 {
1306                    snap.drain(0..len - 50);
1307                }
1308                snap
1309            })
1310            .unwrap_or_default();
1311
1312        let mut out = String::new();
1313        out.push_str("# bee-tui diagnostic bundle\n");
1314        out.push_str(&format!("# generated UTC {now}\n\n"));
1315        out.push_str("## profile\n");
1316        out.push_str(&format!("  name      {}\n", self.api.name));
1317        out.push_str(&format!("  endpoint  {}\n\n", self.api.url));
1318        out.push_str("## health gates\n");
1319        for g in &gates {
1320            out.push_str(&format_gate_line(g));
1321        }
1322        out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
1323        for e in &recent {
1324            let status = e
1325                .status
1326                .map(|s| s.to_string())
1327                .unwrap_or_else(|| "—".into());
1328            let elapsed = e
1329                .elapsed_ms
1330                .map(|ms| format!("{ms}ms"))
1331                .unwrap_or_else(|| "—".into());
1332            out.push_str(&format!(
1333                "  {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
1334                ts = e.ts,
1335                method = e.method,
1336                path = path_only(&e.url),
1337                status = status,
1338                elapsed = elapsed,
1339            ));
1340        }
1341        out.push_str(&format!(
1342            "\n## generated by bee-tui {}\n",
1343            env!("CARGO_PKG_VERSION"),
1344        ));
1345        out
1346    }
1347
1348    fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
1349        while let Ok(action) = self.action_rx.try_recv() {
1350            if action != Action::Tick && action != Action::Render {
1351                debug!("{action:?}");
1352            }
1353            match action {
1354                Action::Tick => {
1355                    self.last_tick_key_events.drain(..);
1356                    // Advance the cold-start spinner once per tick
1357                    // so every screen's "loading…" line shows
1358                    // motion at a consistent cadence.
1359                    theme::advance_spinner();
1360                    // Refresh the supervised Bee's status (cheap
1361                    // non-blocking try_wait). Surfaced in the top
1362                    // bar so a mid-session crash is visible.
1363                    if let Some(sup) = self.supervisor.as_mut() {
1364                        self.bee_status = sup.status();
1365                    }
1366                    // Drain any newly-tailed Bee log lines into the
1367                    // log pane. Bounded loop — the channel is
1368                    // unbounded but try_recv stops at the first
1369                    // empty so we don't block the tick.
1370                    if let Some(rx) = self.bee_log_rx.as_mut() {
1371                        while let Ok((tab, line)) = rx.try_recv() {
1372                            self.log_pane.push_bee(tab, line);
1373                        }
1374                    }
1375                    // Surface async command-result updates (e.g.
1376                    // `:probe-upload` finished). The latest message
1377                    // wins — earlier ones get implicitly overwritten
1378                    // because we keep the loop draining.
1379                    while let Ok(status) = self.cmd_status_rx.try_recv() {
1380                        self.command_status = Some(status);
1381                    }
1382                }
1383                Action::Quit => self.should_quit = true,
1384                Action::Suspend => self.should_suspend = true,
1385                Action::Resume => self.should_suspend = false,
1386                Action::ClearScreen => tui.terminal.clear()?,
1387                Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
1388                Action::Render => self.render(tui)?,
1389                _ => {}
1390            }
1391            let tx = self.action_tx.clone();
1392            for component in self.iter_components_mut() {
1393                if let Some(action) = component.update(action.clone())? {
1394                    tx.send(action)?
1395                };
1396            }
1397        }
1398        Ok(())
1399    }
1400
1401    fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
1402        tui.resize(Rect::new(0, 0, w, h))?;
1403        self.render(tui)?;
1404        Ok(())
1405    }
1406
1407    fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
1408        let active = self.current_screen;
1409        let tx = self.action_tx.clone();
1410        let screens = &mut self.screens;
1411        let log_pane = &mut self.log_pane;
1412        let log_pane_height = log_pane.height();
1413        let command_buffer = self.command_buffer.clone();
1414        let command_suggestion_index = self.command_suggestion_index;
1415        let command_status = self.command_status.clone();
1416        let help_visible = self.help_visible;
1417        let profile = self.api.name.clone();
1418        let endpoint = self.api.url.clone();
1419        let last_ping = self.health_rx.borrow().last_ping;
1420        let now_utc = format_utc_now();
1421        let bee_status_label = if self.supervisor.is_some() && !self.bee_status.is_running() {
1422            // Only show the status when (a) we're acting as the
1423            // supervisor and (b) something is wrong. Hiding the
1424            // happy-path label keeps the metadata line uncluttered.
1425            Some(self.bee_status.label())
1426        } else {
1427            None
1428        };
1429        tui.draw(|frame| {
1430            use ratatui::layout::{Constraint, Layout};
1431            use ratatui::style::{Color, Modifier, Style};
1432            use ratatui::text::{Line, Span};
1433            use ratatui::widgets::Paragraph;
1434
1435            let chunks = Layout::vertical([
1436                Constraint::Length(2),               // top-bar (metadata + tabs)
1437                Constraint::Min(0),                  // active screen
1438                Constraint::Length(1),               // command bar / status line
1439                Constraint::Length(log_pane_height), // tabbed log pane (operator-resizable)
1440            ])
1441            .split(frame.area());
1442
1443            let top_chunks =
1444                Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(chunks[0]);
1445
1446            // Metadata line: profile · endpoint · ping · clock.
1447            let ping_str = match last_ping {
1448                Some(d) => format!("{}ms", d.as_millis()),
1449                None => "—".into(),
1450            };
1451            let t = theme::active();
1452            let mut metadata_spans = vec![
1453                Span::styled(
1454                    " bee-tui ",
1455                    Style::default()
1456                        .fg(Color::Black)
1457                        .bg(t.info)
1458                        .add_modifier(Modifier::BOLD),
1459                ),
1460                Span::raw("  "),
1461                Span::styled(
1462                    profile,
1463                    Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
1464                ),
1465                Span::styled(format!(" @ {endpoint}"), Style::default().fg(t.dim)),
1466                Span::raw("   "),
1467                Span::styled("ping ", Style::default().fg(t.dim)),
1468                Span::styled(ping_str, Style::default().fg(t.info)),
1469                Span::raw("   "),
1470                Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
1471            ];
1472            // Append a Bee-process status chip iff the supervisor is
1473            // active AND something is wrong. Renders red so a crash
1474            // mid-session is impossible to miss in the top bar.
1475            if let Some(label) = bee_status_label.as_ref() {
1476                metadata_spans.push(Span::raw("   "));
1477                metadata_spans.push(Span::styled(
1478                    format!(" {label} "),
1479                    Style::default()
1480                        .fg(Color::Black)
1481                        .bg(t.fail)
1482                        .add_modifier(Modifier::BOLD),
1483                ));
1484            }
1485            let metadata_line = Line::from(metadata_spans);
1486            frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
1487
1488            // Tab strip with the active screen highlighted.
1489            let theme = *theme::active();
1490            let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
1491            for (i, name) in SCREEN_NAMES.iter().enumerate() {
1492                let style = if i == active {
1493                    Style::default()
1494                        .fg(theme.tab_active_fg)
1495                        .bg(theme.tab_active_bg)
1496                        .add_modifier(Modifier::BOLD)
1497                } else {
1498                    Style::default().fg(theme.dim)
1499                };
1500                tabs.push(Span::styled(format!(" {name} "), style));
1501                tabs.push(Span::raw(" "));
1502            }
1503            tabs.push(Span::styled(
1504                ":cmd · Tab to cycle · ? help",
1505                Style::default().fg(theme.dim),
1506            ));
1507            frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
1508
1509            // Active screen
1510            if let Some(screen) = screens.get_mut(active) {
1511                if let Err(err) = screen.draw(frame, chunks[1]) {
1512                    let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
1513                }
1514            }
1515            // Command bar / status line
1516            let prompt = if let Some(buf) = &command_buffer {
1517                Line::from(vec![
1518                    Span::styled(
1519                        ":",
1520                        Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
1521                    ),
1522                    Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
1523                    Span::styled("█", Style::default().fg(t.accent)),
1524                ])
1525            } else {
1526                match &command_status {
1527                    Some(CommandStatus::Info(msg)) => {
1528                        Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
1529                    }
1530                    Some(CommandStatus::Err(msg)) => {
1531                        Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
1532                    }
1533                    None => Line::from(""),
1534                }
1535            };
1536            frame.render_widget(Paragraph::new(prompt), chunks[2]);
1537
1538            // Command suggestion popup — floats above the command bar
1539            // while the operator is typing. Filtered list of known
1540            // verbs that prefix-match the buffer's first token; Up/Down
1541            // navigates, Tab completes. Skipped silently if the
1542            // command bar is closed or no commands match.
1543            if let Some(buf) = &command_buffer {
1544                let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
1545                if !matches.is_empty() {
1546                    draw_command_suggestions(
1547                        frame,
1548                        chunks[2],
1549                        &matches,
1550                        command_suggestion_index,
1551                        &theme,
1552                    );
1553                }
1554            }
1555
1556            // Tabbed log pane
1557            if let Err(err) = log_pane.draw(frame, chunks[3]) {
1558                let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
1559            }
1560
1561            // Help overlay — drawn last so it floats above everything
1562            // else. Centred with a fixed width that fits even narrow
1563            // terminals (≥60 cols). Falls back to the full screen on
1564            // anything narrower.
1565            if help_visible {
1566                draw_help_overlay(frame, frame.area(), active, &theme);
1567            }
1568        })?;
1569        Ok(())
1570    }
1571}
1572
1573/// Render the command-suggestion popup just above the command bar.
1574/// Floats over the active screen (uses `Clear` to blank what's
1575/// underneath) and highlights the row at `selected` so Up/Down
1576/// navigation is visible. Auto-scrolls if the filtered list exceeds
1577/// the visible window — operators see at most `MAX_VISIBLE` rows at
1578/// a time.
1579fn draw_command_suggestions(
1580    frame: &mut ratatui::Frame,
1581    bar_rect: ratatui::layout::Rect,
1582    matches: &[&(&str, &str)],
1583    selected: usize,
1584    theme: &theme::Theme,
1585) {
1586    use ratatui::layout::Rect;
1587    use ratatui::style::{Modifier, Style};
1588    use ratatui::text::{Line, Span};
1589    use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1590
1591    const MAX_VISIBLE: usize = 10;
1592    let visible_rows = matches.len().min(MAX_VISIBLE);
1593    if visible_rows == 0 {
1594        return;
1595    }
1596    let height = (visible_rows as u16) + 2; // +2 for top + bottom borders
1597    // Width = longest "name  description" line + borders + padding,
1598    // capped at 80% of the screen so wide descriptions don't push
1599    // the popup off the edge.
1600    let widest = matches
1601        .iter()
1602        .map(|(name, desc)| name.len() + desc.len() + 6)
1603        .max()
1604        .unwrap_or(40)
1605        .min(bar_rect.width as usize);
1606    let width = (widest as u16 + 2).min(bar_rect.width);
1607    // Anchor above the command bar; if the popup would clip the top
1608    // of the screen, fall back to as much vertical room as we have.
1609    let bottom = bar_rect.y;
1610    let y = bottom.saturating_sub(height);
1611    let popup = Rect {
1612        x: bar_rect.x,
1613        y,
1614        width,
1615        height: bottom - y,
1616    };
1617
1618    // Auto-scroll: keep `selected` inside the visible window.
1619    let scroll_start = if selected >= visible_rows {
1620        selected + 1 - visible_rows
1621    } else {
1622        0
1623    };
1624    let visible_slice = &matches[scroll_start..(scroll_start + visible_rows).min(matches.len())];
1625
1626    let mut lines: Vec<Line> = Vec::with_capacity(visible_slice.len());
1627    for (i, (name, desc)) in visible_slice.iter().enumerate() {
1628        let absolute_idx = scroll_start + i;
1629        let is_selected = absolute_idx == selected;
1630        let row_style = if is_selected {
1631            Style::default()
1632                .fg(theme.tab_active_fg)
1633                .bg(theme.tab_active_bg)
1634                .add_modifier(Modifier::BOLD)
1635        } else {
1636            Style::default()
1637        };
1638        let cursor = if is_selected { "▸ " } else { "  " };
1639        lines.push(Line::from(vec![
1640            Span::styled(format!("{cursor}:{name:<16}  "), row_style),
1641            Span::styled(
1642                desc.to_string(),
1643                if is_selected {
1644                    row_style
1645                } else {
1646                    Style::default().fg(theme.dim)
1647                },
1648            ),
1649        ]));
1650    }
1651
1652    // Title shows pagination state when the list overflows.
1653    let title = if matches.len() > MAX_VISIBLE {
1654        format!(" :commands ({}/{}) ", selected + 1, matches.len())
1655    } else {
1656        " :commands ".to_string()
1657    };
1658
1659    frame.render_widget(Clear, popup);
1660    frame.render_widget(
1661        Paragraph::new(lines).block(
1662            Block::default()
1663                .borders(Borders::ALL)
1664                .border_style(Style::default().fg(theme.accent))
1665                .title(title),
1666        ),
1667        popup,
1668    );
1669}
1670
1671/// Render the `?` help overlay. Pulls a per-screen keymap from
1672/// [`screen_keymap`] and pairs it with the global keys (Tab, `:`,
1673/// `q`). Drawn as a centred floating box; everything outside is
1674/// dimmed via a [`Clear`] underlay.
1675fn draw_help_overlay(
1676    frame: &mut ratatui::Frame,
1677    area: ratatui::layout::Rect,
1678    active_screen: usize,
1679    theme: &theme::Theme,
1680) {
1681    use ratatui::layout::Rect;
1682    use ratatui::style::{Modifier, Style};
1683    use ratatui::text::{Line, Span};
1684    use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1685
1686    let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
1687    let screen_rows = screen_keymap(active_screen);
1688    let global_rows: &[(&str, &str)] = &[
1689        ("Tab", "next screen"),
1690        ("Shift+Tab", "previous screen"),
1691        ("[ / ]", "previous / next log-pane tab"),
1692        ("+ / -", "grow / shrink log pane"),
1693        ("Shift+↑/↓", "scroll log pane (1 line); pauses auto-tail"),
1694        ("Shift+PgUp/PgDn", "scroll log pane (10 lines)"),
1695        ("Shift+←/→", "pan log pane horizontally (8 cols)"),
1696        ("Shift+End", "resume auto-tail + reset horizontal pan"),
1697        ("?", "toggle this help"),
1698        (":", "open command bar"),
1699        ("qq", "quit (double-tap; or :q)"),
1700        ("Ctrl+C / Ctrl+D", "quit immediately"),
1701    ];
1702
1703    // Layout: pick the smaller of (screen size, 70x22) so we always
1704    // fit on small terminals.
1705    let w = area.width.min(72);
1706    let h = area.height.min(22);
1707    let x = area.x + (area.width.saturating_sub(w)) / 2;
1708    let y = area.y + (area.height.saturating_sub(h)) / 2;
1709    let rect = Rect {
1710        x,
1711        y,
1712        width: w,
1713        height: h,
1714    };
1715
1716    let mut lines: Vec<Line> = Vec::new();
1717    lines.push(Line::from(vec![
1718        Span::styled(
1719            format!(" {screen_name} "),
1720            Style::default()
1721                .fg(theme.tab_active_fg)
1722                .bg(theme.tab_active_bg)
1723                .add_modifier(Modifier::BOLD),
1724        ),
1725        Span::raw("   screen-specific keys"),
1726    ]));
1727    lines.push(Line::from(""));
1728    if screen_rows.is_empty() {
1729        lines.push(Line::from(Span::styled(
1730            "  (no extra keys for this screen — use the command bar via :)",
1731            Style::default()
1732                .fg(theme.dim)
1733                .add_modifier(Modifier::ITALIC),
1734        )));
1735    } else {
1736        for (key, desc) in screen_rows {
1737            lines.push(format_help_row(key, desc, theme));
1738        }
1739    }
1740    lines.push(Line::from(""));
1741    lines.push(Line::from(Span::styled(
1742        "  global",
1743        Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
1744    )));
1745    for (key, desc) in global_rows {
1746        lines.push(format_help_row(key, desc, theme));
1747    }
1748    lines.push(Line::from(""));
1749    lines.push(Line::from(Span::styled(
1750        "  Esc / ? / q to dismiss",
1751        Style::default()
1752            .fg(theme.dim)
1753            .add_modifier(Modifier::ITALIC),
1754    )));
1755
1756    // `Clear` blanks the underlying rendered region so the overlay
1757    // doesn't ghost over screen content.
1758    frame.render_widget(Clear, rect);
1759    frame.render_widget(
1760        Paragraph::new(lines).block(
1761            Block::default()
1762                .borders(Borders::ALL)
1763                .border_style(Style::default().fg(theme.accent))
1764                .title(" help "),
1765        ),
1766        rect,
1767    );
1768}
1769
1770fn format_help_row<'a>(
1771    key: &'a str,
1772    desc: &'a str,
1773    theme: &theme::Theme,
1774) -> ratatui::text::Line<'a> {
1775    use ratatui::style::{Modifier, Style};
1776    use ratatui::text::{Line, Span};
1777    Line::from(vec![
1778        Span::raw("  "),
1779        Span::styled(
1780            format!("{key:<16}"),
1781            Style::default()
1782                .fg(theme.accent)
1783                .add_modifier(Modifier::BOLD),
1784        ),
1785        Span::raw("  "),
1786        Span::raw(desc),
1787    ])
1788}
1789
1790/// Per-screen keymap rows, indexed by the same position as
1791/// [`SCREEN_NAMES`]. Edit here when a screen grows new keys —
1792/// no other place needs updating.
1793fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
1794    match active_screen {
1795        // 0: Health — read-only
1796        1 => &[
1797            ("↑↓ / j k", "move row selection"),
1798            ("Enter", "drill batch — bucket histogram + worst-N"),
1799            ("Esc", "close drill"),
1800        ],
1801        // 2: Swap — read-only
1802        3 => &[("r", "run on-demand rchash benchmark")],
1803        4 => &[
1804            ("↑↓ / j k", "move peer selection"),
1805            (
1806                "Enter",
1807                "drill peer — balance / cheques / settlement / ping",
1808            ),
1809            ("Esc", "close drill"),
1810        ],
1811        // 5: Network — read-only
1812        // 6: Warmup — read-only
1813        // 7: API — read-only
1814        8 => &[
1815            ("↑↓ / j k", "scroll one row"),
1816            ("PgUp / PgDn", "scroll ten rows"),
1817            ("Home", "back to top"),
1818        ],
1819        // 9: Pins — selectable rows + on-demand integrity check.
1820        9 => &[
1821            ("↑↓ / j k", "move row selection"),
1822            ("Enter", "integrity-check the highlighted pin"),
1823            ("c", "integrity-check every unchecked pin"),
1824            ("s", "cycle sort: ref order / bad first / by size"),
1825        ],
1826        _ => &[],
1827    }
1828}
1829
1830/// Construct every cockpit screen with receivers from the supplied
1831/// hub. Extracted so `App::new` and the `:context` profile-switcher
1832/// can share the wiring — the screen list is the same on every
1833/// connection, only the underlying watch hub changes.
1834///
1835/// Order matters — the [`SCREEN_NAMES`] table assumes index 0 is
1836/// Health, 1 is Stamps, 2 is Swap, 3 is Lottery, 4 is Peers, 5 is
1837/// Network, 6 is Warmup, 7 is API, 8 is Tags, 9 is Pins.
1838fn build_screens(api: &Arc<ApiClient>, watch: &BeeWatch) -> Vec<Box<dyn Component>> {
1839    let health = Health::new(api.clone(), watch.health(), watch.topology());
1840    let stamps = Stamps::new(api.clone(), watch.stamps());
1841    let swap = Swap::new(watch.swap());
1842    let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
1843    let peers = Peers::new(api.clone(), watch.topology());
1844    let network = Network::new(watch.network(), watch.topology());
1845    let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
1846    let api_health = ApiHealth::new(
1847        api.clone(),
1848        watch.health(),
1849        watch.transactions(),
1850        log_capture::handle(),
1851    );
1852    let tags = Tags::new(watch.tags());
1853    let pins = Pins::new(api.clone(), watch.pins());
1854    vec![
1855        Box::new(health),
1856        Box::new(stamps),
1857        Box::new(swap),
1858        Box::new(lottery),
1859        Box::new(peers),
1860        Box::new(network),
1861        Box::new(warmup),
1862        Box::new(api_health),
1863        Box::new(tags),
1864        Box::new(pins),
1865    ]
1866}
1867
1868/// Build the 4104-byte (8 + 4096) synthetic chunk that
1869/// `:probe-upload` ships at Bee. Timestamp-randomised so each
1870/// invocation produces a unique chunk address — Bee's
1871/// content-addressing dedup would otherwise short-circuit the
1872/// second probe on a fresh batch and skew the latency reading.
1873/// Returns `Vec<u8>`, which `bee::FileApi::upload_chunk` accepts via
1874/// its `impl Into<bytes::Bytes>` parameter.
1875fn build_synthetic_probe_chunk() -> Vec<u8> {
1876    use std::time::{SystemTime, UNIX_EPOCH};
1877    let nanos = SystemTime::now()
1878        .duration_since(UNIX_EPOCH)
1879        .map(|d| d.as_nanos())
1880        .unwrap_or(0);
1881    let mut data = Vec::with_capacity(8 + 4096);
1882    // Span: little-endian u64 with the payload length.
1883    data.extend_from_slice(&4096u64.to_le_bytes());
1884    // Payload: 16 bytes of timestamp + zero-padding to 4096.
1885    data.extend_from_slice(&nanos.to_le_bytes());
1886    data.resize(8 + 4096, 0);
1887    data
1888}
1889
1890/// Truncate a hex string to a short prefix with an ellipsis. Used by
1891/// `:probe-upload` for the human-readable batch + reference labels.
1892fn short_hex(hex: &str, len: usize) -> String {
1893    if hex.len() > len {
1894        format!("{}…", &hex[..len])
1895    } else {
1896        hex.to_string()
1897    }
1898}
1899
1900/// Build the closure the metrics HTTP handler invokes on each
1901/// scrape. Captures cloned `BeeWatch` receivers (cheap — they're
1902/// `Arc`-backed) plus the log-capture handle, then re-reads the
1903/// latest snapshot of each on every call. Returns an `Arc<Fn>`
1904/// matching `metrics_server::RenderFn`.
1905fn build_metrics_render_fn(
1906    watch: BeeWatch,
1907    log_capture: Option<log_capture::LogCapture>,
1908) -> crate::metrics_server::RenderFn {
1909    use std::time::{SystemTime, UNIX_EPOCH};
1910    Arc::new(move || {
1911        let health = watch.health().borrow().clone();
1912        let stamps = watch.stamps().borrow().clone();
1913        let swap = watch.swap().borrow().clone();
1914        let lottery = watch.lottery().borrow().clone();
1915        let topology = watch.topology().borrow().clone();
1916        let network = watch.network().borrow().clone();
1917        let transactions = watch.transactions().borrow().clone();
1918        let recent = log_capture
1919            .as_ref()
1920            .map(|c| c.snapshot())
1921            .unwrap_or_default();
1922        let call_stats = crate::components::api_health::call_stats_for(&recent);
1923        let now_unix = SystemTime::now()
1924            .duration_since(UNIX_EPOCH)
1925            .map(|d| d.as_secs() as i64)
1926            .unwrap_or(0);
1927        let inputs = crate::metrics::MetricsInputs {
1928            bee_tui_version: env!("CARGO_PKG_VERSION"),
1929            health: &health,
1930            stamps: &stamps,
1931            swap: &swap,
1932            lottery: &lottery,
1933            topology: &topology,
1934            network: &network,
1935            transactions: &transactions,
1936            call_stats: &call_stats,
1937            now_unix,
1938        };
1939        crate::metrics::render(&inputs)
1940    })
1941}
1942
1943fn format_gate_line(g: &Gate) -> String {
1944    let glyphs = crate::theme::active().glyphs;
1945    let glyph = match g.status {
1946        GateStatus::Pass => glyphs.pass,
1947        GateStatus::Warn => glyphs.warn,
1948        GateStatus::Fail => glyphs.fail,
1949        GateStatus::Unknown => glyphs.bullet,
1950    };
1951    let mut s = format!(
1952        "  [{glyph}] {label:<28} {value}\n",
1953        label = g.label,
1954        value = g.value
1955    );
1956    if let Some(why) = &g.why {
1957        s.push_str(&format!("        {} {why}\n", glyphs.continuation));
1958    }
1959    s
1960}
1961
1962/// Strip scheme + host from a URL, leaving only the path + query.
1963/// Mirrors the redaction the S10 command-log pane applies on render.
1964fn path_only(url: &str) -> String {
1965    if let Some(idx) = url.find("//") {
1966        let after_scheme = &url[idx + 2..];
1967        if let Some(slash) = after_scheme.find('/') {
1968            return after_scheme[slash..].to_string();
1969        }
1970        return "/".into();
1971    }
1972    url.to_string()
1973}
1974
1975/// Format the current wall-clock UTC time as `HH:MM:SS`. We compute
1976/// from `SystemTime::now()` directly so the binary stays free of a
1977/// chrono / time dep just for this one display string.
1978/// Append-write to `path`. Used by the `:pins-check` background task
1979/// to stream NDJSON-style results into a file the operator can
1980/// `tail -f`.
1981fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
1982    use std::io::Write;
1983    let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
1984    f.write_all(s.as_bytes())
1985}
1986
1987/// Bee returns logger verbosity as a free-form string — usually
1988/// `"all"`, `"trace"`, `"debug"`, `"info"`, `"warning"`, `"error"`,
1989/// `"none"`, plus the legacy numeric forms `"1"`/`"2"`/`"3"`. Map to
1990/// a coarse rank so the noisier loggers sort to the top of the
1991/// `:loggers` dump. Unknown strings get rank 0 (silent end).
1992fn verbosity_rank(s: &str) -> u8 {
1993    match s {
1994        "all" | "trace" => 5,
1995        "debug" => 4,
1996        "info" | "1" => 3,
1997        "warning" | "warn" | "2" => 2,
1998        "error" | "3" => 1,
1999        _ => 0,
2000    }
2001}
2002
2003/// Drop characters that are unsafe in a filename. Profile names come
2004/// from the user's `config.toml`, so we accept what's in there but
2005/// keep the path well-behaved on every shell.
2006fn sanitize_for_filename(s: &str) -> String {
2007    s.chars()
2008        .map(|c| match c {
2009            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
2010            _ => '-',
2011        })
2012        .collect()
2013}
2014
2015/// Outcome of a `q` keystroke under the double-tap-to-quit guard.
2016/// Pure data so [`resolve_quit_press`] can be unit-tested without
2017/// any TUI / event-loop scaffolding.
2018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2019pub enum QuitResolution {
2020    /// Second `q` arrived inside the confirmation window — quit.
2021    Confirm,
2022    /// First `q`, or a second `q` after the window expired —
2023    /// remember the timestamp and surface the hint.
2024    Pending,
2025}
2026
2027/// Decide what to do with a `q` press given the previous press
2028/// timestamp (if any) and the current time. The window is supplied
2029/// rather than read from a constant so tests can use short windows
2030/// without sleeping.
2031fn resolve_quit_press(prev: Option<Instant>, now: Instant, window: Duration) -> QuitResolution {
2032    match prev {
2033        Some(t) if now.duration_since(t) <= window => QuitResolution::Confirm,
2034        _ => QuitResolution::Pending,
2035    }
2036}
2037
2038fn format_utc_now() -> String {
2039    let secs = SystemTime::now()
2040        .duration_since(UNIX_EPOCH)
2041        .map(|d| d.as_secs())
2042        .unwrap_or(0);
2043    let secs_in_day = secs % 86_400;
2044    let h = secs_in_day / 3_600;
2045    let m = (secs_in_day % 3_600) / 60;
2046    let s = secs_in_day % 60;
2047    format!("{h:02}:{m:02}:{s:02}")
2048}
2049
2050#[cfg(test)]
2051mod tests {
2052    use super::*;
2053
2054    #[test]
2055    fn format_utc_now_returns_eight_chars() {
2056        let s = format_utc_now();
2057        assert_eq!(s.len(), 8);
2058        assert_eq!(s.chars().nth(2), Some(':'));
2059        assert_eq!(s.chars().nth(5), Some(':'));
2060    }
2061
2062    #[test]
2063    fn path_only_strips_scheme_and_host() {
2064        assert_eq!(path_only("http://10.0.1.5:1633/status"), "/status");
2065        assert_eq!(
2066            path_only("https://bee.example.com/stamps?limit=10"),
2067            "/stamps?limit=10"
2068        );
2069    }
2070
2071    #[test]
2072    fn path_only_handles_no_path() {
2073        assert_eq!(path_only("http://localhost:1633"), "/");
2074    }
2075
2076    #[test]
2077    fn path_only_passes_relative_through() {
2078        assert_eq!(path_only("/already/relative"), "/already/relative");
2079    }
2080
2081    #[test]
2082    fn sanitize_for_filename_keeps_safe_chars() {
2083        assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
2084        assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
2085    }
2086
2087    #[test]
2088    fn sanitize_for_filename_replaces_unsafe_chars() {
2089        assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
2090        assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
2091    }
2092
2093    #[test]
2094    fn resolve_quit_press_first_press_is_pending() {
2095        let now = Instant::now();
2096        assert_eq!(
2097            resolve_quit_press(None, now, Duration::from_millis(1500)),
2098            QuitResolution::Pending
2099        );
2100    }
2101
2102    #[test]
2103    fn resolve_quit_press_second_press_inside_window_confirms() {
2104        let first = Instant::now();
2105        let window = Duration::from_millis(1500);
2106        let second = first + Duration::from_millis(500);
2107        assert_eq!(
2108            resolve_quit_press(Some(first), second, window),
2109            QuitResolution::Confirm
2110        );
2111    }
2112
2113    #[test]
2114    fn resolve_quit_press_second_press_after_window_resets_to_pending() {
2115        // A `q` long after the previous press should restart the
2116        // double-tap window — the operator hasn't really "meant it
2117        // twice in a row".
2118        let first = Instant::now();
2119        let window = Duration::from_millis(1500);
2120        let second = first + Duration::from_millis(2_000);
2121        assert_eq!(
2122            resolve_quit_press(Some(first), second, window),
2123            QuitResolution::Pending
2124        );
2125    }
2126
2127    #[test]
2128    fn resolve_quit_press_at_window_boundary_confirms() {
2129        // Exactly at the boundary the press counts as confirm —
2130        // operators tapping in rhythm shouldn't be punished by jitter.
2131        let first = Instant::now();
2132        let window = Duration::from_millis(1500);
2133        let second = first + window;
2134        assert_eq!(
2135            resolve_quit_press(Some(first), second, window),
2136            QuitResolution::Confirm
2137        );
2138    }
2139
2140    #[test]
2141    fn screen_keymap_covers_drill_screens() {
2142        // Stamps (1) and Peers (4) are the two screens with drill
2143        // panes — both must list ↑↓ / Enter / Esc in the help.
2144        for idx in [1usize, 4] {
2145            let rows = screen_keymap(idx);
2146            assert!(
2147                rows.iter().any(|(k, _)| k.contains("Enter")),
2148                "screen {idx} keymap must mention Enter (drill)"
2149            );
2150            assert!(
2151                rows.iter().any(|(k, _)| k.contains("Esc")),
2152                "screen {idx} keymap must mention Esc (close drill)"
2153            );
2154        }
2155    }
2156
2157    #[test]
2158    fn screen_keymap_lottery_advertises_rchash() {
2159        let rows = screen_keymap(3);
2160        assert!(rows.iter().any(|(k, _)| k.contains("r")));
2161    }
2162
2163    #[test]
2164    fn screen_keymap_unknown_index_is_empty_not_panic() {
2165        assert!(screen_keymap(999).is_empty());
2166    }
2167
2168    #[test]
2169    fn verbosity_rank_orders_loud_to_silent() {
2170        assert!(verbosity_rank("all") > verbosity_rank("debug"));
2171        assert!(verbosity_rank("debug") > verbosity_rank("info"));
2172        assert!(verbosity_rank("info") > verbosity_rank("warning"));
2173        assert!(verbosity_rank("warning") > verbosity_rank("error"));
2174        assert!(verbosity_rank("error") > verbosity_rank("unknown"));
2175        // Numeric and named forms sort identically.
2176        assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
2177        assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
2178    }
2179
2180    #[test]
2181    fn filter_command_suggestions_empty_buffer_returns_all() {
2182        let matches = filter_command_suggestions("", KNOWN_COMMANDS);
2183        assert_eq!(matches.len(), KNOWN_COMMANDS.len());
2184    }
2185
2186    #[test]
2187    fn filter_command_suggestions_prefix_matches_case_insensitive() {
2188        let matches = filter_command_suggestions("Bu", KNOWN_COMMANDS);
2189        let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
2190        assert!(names.contains(&"buy-preview"));
2191        assert!(names.contains(&"buy-suggest"));
2192        assert_eq!(names.len(), 2);
2193    }
2194
2195    #[test]
2196    fn filter_command_suggestions_unknown_prefix_is_empty() {
2197        let matches = filter_command_suggestions("xyz", KNOWN_COMMANDS);
2198        assert!(matches.is_empty());
2199    }
2200
2201    #[test]
2202    fn filter_command_suggestions_uses_first_token_only() {
2203        // `:topup-preview a1b2 1000` — the prefix is the verb, not
2204        // any of the args.
2205        let matches = filter_command_suggestions("topup-preview a1b2 1000", KNOWN_COMMANDS);
2206        let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
2207        assert_eq!(names, vec!["topup-preview"]);
2208    }
2209
2210    #[test]
2211    fn probe_chunk_is_4104_bytes_with_correct_span() {
2212        // span(8) + payload(4096) = 4104, span = 4096 little-endian.
2213        let chunk = build_synthetic_probe_chunk();
2214        assert_eq!(chunk.len(), 4104);
2215        let span = u64::from_le_bytes(chunk[..8].try_into().unwrap());
2216        assert_eq!(span, 4096);
2217    }
2218
2219    #[test]
2220    fn probe_chunk_payloads_are_unique_per_call() {
2221        // Timestamp-randomised → two consecutive builds must differ.
2222        // The randomness lives in payload bytes 0..16, so compare just
2223        // that window to keep the test deterministic against the
2224        // zero-padded tail.
2225        let a = build_synthetic_probe_chunk();
2226        // tiny sleep so the nanosecond clock is guaranteed to advance
2227        std::thread::sleep(Duration::from_micros(1));
2228        let b = build_synthetic_probe_chunk();
2229        assert_ne!(&a[8..24], &b[8..24]);
2230    }
2231
2232    #[test]
2233    fn short_hex_truncates_with_ellipsis() {
2234        assert_eq!(short_hex("a1b2c3d4e5f6", 8), "a1b2c3d4…");
2235        assert_eq!(short_hex("short", 8), "short");
2236        assert_eq!(short_hex("abcdefgh", 8), "abcdefgh");
2237    }
2238}