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