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