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