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