Skip to main content

bee_tui/
app.rs

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