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