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