1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
4
5use color_eyre::eyre::eyre;
6use crossterm::event::KeyEvent;
7use ratatui::prelude::Rect;
8use serde::{Deserialize, Serialize};
9use tokio::sync::{mpsc, watch};
10use tokio_util::sync::CancellationToken;
11use tracing::{debug, info};
12
13use crate::{
14 action::Action,
15 api::ApiClient,
16 bee_supervisor::{BeeStatus, BeeSupervisor},
17 components::{
18 Component,
19 api_health::ApiHealth,
20 feed_timeline::FeedTimeline,
21 health::{Gate, GateStatus, Health},
22 log_pane::{BeeLogLine, LogPane, LogTab},
23 lottery::Lottery,
24 manifest::Manifest,
25 network::Network,
26 peers::Peers,
27 pins::Pins,
28 pubsub::Pubsub,
29 stamps::Stamps,
30 swap::Swap,
31 tags::Tags,
32 warmup::Warmup,
33 watchlist::Watchlist,
34 },
35 config::Config,
36 config_doctor, durability, economics_oracle, log_capture,
37 manifest_walker::{self, InspectResult},
38 pprof_bundle, stamp_preview,
39 state::State,
40 theme,
41 tui::{Event, Tui},
42 utility_verbs, version_check,
43 watch::{BeeWatch, HealthSnapshot, RefreshProfile},
44};
45
46pub struct App {
47 config: Config,
48 tick_rate: f64,
49 frame_rate: f64,
50 screens: Vec<Box<dyn Component>>,
54 current_screen: usize,
56 log_pane: LogPane,
60 state_path: PathBuf,
63 should_quit: bool,
64 should_suspend: bool,
65 mode: Mode,
66 last_tick_key_events: Vec<KeyEvent>,
67 action_tx: mpsc::UnboundedSender<Action>,
68 action_rx: mpsc::UnboundedReceiver<Action>,
69 root_cancel: CancellationToken,
72 #[allow(dead_code)]
75 api: Arc<ApiClient>,
76 watch: BeeWatch,
78 health_rx: watch::Receiver<HealthSnapshot>,
81 command_buffer: Option<String>,
84 command_suggestion_index: usize,
89 command_status: Option<CommandStatus>,
93 help_visible: bool,
96 quit_pending: Option<Instant>,
102 supervisor: Option<BeeSupervisor>,
106 bee_status: BeeStatus,
111 bee_log_rx: Option<mpsc::UnboundedReceiver<(LogTab, BeeLogLine)>>,
115 cmd_status_tx: mpsc::UnboundedSender<CommandStatus>,
121 cmd_status_rx: mpsc::UnboundedReceiver<CommandStatus>,
122 durability_tx: mpsc::UnboundedSender<crate::durability::DurabilityResult>,
128 durability_rx: mpsc::UnboundedReceiver<crate::durability::DurabilityResult>,
129 feed_timeline_tx: mpsc::UnboundedSender<FeedTimelineMessage>,
133 feed_timeline_rx: mpsc::UnboundedReceiver<FeedTimelineMessage>,
134 watch_refs: std::collections::HashMap<String, CancellationToken>,
138 pubsub_subs: std::collections::HashMap<String, CancellationToken>,
143 pubsub_history: crate::pubsub::HistoryWriter,
147 pubsub_msg_tx: mpsc::UnboundedSender<crate::pubsub::PubsubMessage>,
150 pubsub_msg_rx: mpsc::UnboundedReceiver<crate::pubsub::PubsubMessage>,
151 alert_state: crate::alerts::AlertState,
158}
159
160const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
163
164#[derive(Debug, Clone)]
167pub enum CommandStatus {
168 Info(String),
169 Err(String),
170}
171
172#[derive(Debug, Clone)]
176pub enum FeedTimelineMessage {
177 Loaded(crate::feed_timeline::Timeline),
178 Failed(String),
179}
180
181const SCREEN_NAMES: &[&str] = &[
184 "Health",
185 "Stamps",
186 "Swap",
187 "Lottery",
188 "Peers",
189 "Network",
190 "Warmup",
191 "API",
192 "Tags",
193 "Pins",
194 "Manifest",
195 "Watchlist",
196 "FeedTimeline",
197 "Pubsub",
198];
199
200const KNOWN_COMMANDS: &[(&str, &str)] = &[
211 ("health", "S1 Health screen"),
212 ("stamps", "S2 Stamps screen"),
213 ("swap", "S3 SWAP / cheques screen"),
214 ("lottery", "S4 Lottery + rchash"),
215 ("peers", "S6 Peers + bin saturation"),
216 ("network", "S7 Network / NAT"),
217 ("warmup", "S5 Warmup checklist"),
218 ("api", "S8 RPC / API health"),
219 ("tags", "S9 Tags / uploads"),
220 ("pins", "S11 Pins screen"),
221 ("topup-preview", "<batch> <amount-plur> — predict topup"),
222 ("dilute-preview", "<batch> <new-depth> — predict dilute"),
223 ("extend-preview", "<batch> <duration> — predict extend"),
224 ("buy-preview", "<depth> <amount-plur> — predict fresh buy"),
225 ("buy-suggest", "<size> <duration> — minimum (depth, amount)"),
226 (
227 "plan-batch",
228 "<batch> [usage-thr] [ttl-thr] [extra-depth] — unified topup+dilute plan",
229 ),
230 (
231 "check-version",
232 "compare running Bee version with GitHub's latest release",
233 ),
234 (
235 "config-doctor",
236 "audit bee.yaml for deprecated keys (read-only, never modifies)",
237 ),
238 ("price", "fetch xBZZ → USD spot price"),
239 (
240 "basefee",
241 "fetch Gnosis basefee + tip (requires [economics].gnosis_rpc_url)",
242 ),
243 (
244 "probe-upload",
245 "<batch> — single 4 KiB chunk, end-to-end probe",
246 ),
247 (
248 "upload-file",
249 "<path> <batch> — upload a single local file, return Swarm ref",
250 ),
251 (
252 "upload-collection",
253 "<dir> <batch> — recursive directory upload, return Swarm ref",
254 ),
255 (
256 "feed-probe",
257 "<owner> <topic> — latest update for a feed (read-only lookup)",
258 ),
259 (
260 "feed-timeline",
261 "<owner> <topic> [N] — walk a feed's history, open S14",
262 ),
263 (
264 "manifest",
265 "<ref> — open Mantaray tree browser at a reference",
266 ),
267 (
268 "inspect",
269 "<ref> — what is this? auto-detects manifest vs raw chunk",
270 ),
271 (
272 "durability-check",
273 "<ref> — walk chunk graph, report total / lost / errors",
274 ),
275 (
276 "grantees-list",
277 "<ref> — list ACT grantees on a reference (read-only)",
278 ),
279 (
280 "watch-ref",
281 "<ref> [interval] — run :durability-check every interval (default 60s)",
282 ),
283 (
284 "watch-ref-stop",
285 "[ref] — stop one :watch-ref daemon (or all if no arg)",
286 ),
287 (
288 "pubsub-pss",
289 "<topic> — subscribe to PSS messages on a topic, surface in S15",
290 ),
291 (
292 "pubsub-gsoc",
293 "<owner> <identifier> — subscribe to a GSOC SOC, surface in S15",
294 ),
295 (
296 "pubsub-stop",
297 "[sub-id] — stop one pubsub subscription (or all if no arg)",
298 ),
299 (
300 "pubsub-filter",
301 "<substring> — show only messages whose channel/preview contains substring",
302 ),
303 (
304 "pubsub-filter-clear",
305 "remove the active S15 substring filter",
306 ),
307 ("watchlist", "S13 Watchlist — durability-check history"),
308 (
309 "hash",
310 "<path> — Swarm reference of a local file/dir (offline)",
311 ),
312 ("cid", "<ref> [manifest|feed] — encode reference as CID"),
313 ("depth-table", "Print canonical depth → capacity table"),
314 (
315 "gsoc-mine",
316 "<overlay> <id> — mine a GSOC signer (CPU work)",
317 ),
318 (
319 "pss-target",
320 "<overlay> — first 4 hex chars (Bee's max prefix)",
321 ),
322 (
323 "diagnose",
324 "[--pprof[=N]] Export snapshot (+ optional CPU profile + trace)",
325 ),
326 ("pins-check", "Bulk integrity walk to a file"),
327 ("loggers", "Dump live logger registry"),
328 ("set-logger", "<expr> <level> — change a logger's verbosity"),
329 ("context", "<name> — switch node profile"),
330 ("quit", "Exit the cockpit"),
331];
332
333fn parse_pprof_arg(line: &str) -> Option<u32> {
338 for tok in line.split_whitespace() {
339 if tok == "--pprof" {
340 return Some(60);
341 }
342 if let Some(rest) = tok.strip_prefix("--pprof=") {
343 if let Ok(n) = rest.parse::<u32>() {
344 return Some(n.clamp(1, 600));
345 }
346 }
347 }
348 None
349}
350
351fn filter_command_suggestions<'a>(
355 buffer: &str,
356 catalog: &'a [(&'a str, &'a str)],
357) -> Vec<&'a (&'a str, &'a str)> {
358 let head = buffer
359 .split_whitespace()
360 .next()
361 .unwrap_or("")
362 .to_ascii_lowercase();
363 catalog
364 .iter()
365 .filter(|(name, _)| name.starts_with(&head))
366 .collect()
367}
368
369#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
370pub enum Mode {
371 #[default]
372 Home,
373}
374
375#[derive(Debug, Default)]
378pub struct AppOverrides {
379 pub ascii: bool,
381 pub no_color: bool,
383 pub bee_bin: Option<PathBuf>,
385 pub bee_config: Option<PathBuf>,
387}
388
389const BEE_API_READY_TIMEOUT: Duration = Duration::from_secs(60);
394
395impl App {
396 pub async fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
397 Self::with_overrides(tick_rate, frame_rate, AppOverrides::default()).await
398 }
399
400 pub async fn with_overrides(
405 tick_rate: f64,
406 frame_rate: f64,
407 overrides: AppOverrides,
408 ) -> color_eyre::Result<Self> {
409 let (action_tx, action_rx) = mpsc::unbounded_channel();
410 let (cmd_status_tx, cmd_status_rx) = mpsc::unbounded_channel();
411 let (durability_tx, durability_rx) = mpsc::unbounded_channel();
412 let (feed_timeline_tx, feed_timeline_rx) = mpsc::unbounded_channel();
413 let (pubsub_msg_tx, pubsub_msg_rx) = mpsc::unbounded_channel();
414 let config = Config::new()?;
415
416 let pubsub_history = match config.pubsub.history_file.as_deref() {
420 Some(path) => match crate::pubsub::open_history_writer(path).await {
421 Ok(w) => w,
422 Err(e) => {
423 tracing::warn!(target: "bee_tui::pubsub", "history file disabled: {e}");
424 None
425 }
426 },
427 None => None,
428 };
429 let force_no_color = overrides.no_color || theme::no_color_env();
432 theme::install_with_overrides(&config.ui, force_no_color, overrides.ascii);
433
434 let node = config
437 .active_node()
438 .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
439 let api = Arc::new(ApiClient::from_node(node)?);
440
441 let bee_bin = overrides
443 .bee_bin
444 .or_else(|| config.bee.as_ref().map(|b| b.bin.clone()));
445 let bee_config = overrides
446 .bee_config
447 .or_else(|| config.bee.as_ref().map(|b| b.config.clone()));
448 let bee_logs = config
451 .bee
452 .as_ref()
453 .map(|b| b.logs.clone())
454 .unwrap_or_default();
455 let supervisor = match (bee_bin, bee_config) {
456 (Some(bin), Some(cfg)) => {
457 eprintln!("bee-tui: spawning bee {bin:?} --config {cfg:?}");
458 let mut sup = BeeSupervisor::spawn(&bin, &cfg, bee_logs)?;
459 eprintln!(
460 "bee-tui: log → {} (will appear in the cockpit's bottom pane)",
461 sup.log_path().display()
462 );
463 eprintln!(
464 "bee-tui: waiting for {} to respond on /health (up to {:?})...",
465 api.url, BEE_API_READY_TIMEOUT
466 );
467 sup.wait_for_api(&api.url, BEE_API_READY_TIMEOUT).await?;
468 eprintln!("bee-tui: bee ready, opening cockpit");
469 Some(sup)
470 }
471 (Some(_), None) | (None, Some(_)) => {
472 return Err(eyre!(
473 "[bee].bin and [bee].config must both be set (or both unset). \
474 Use --bee-bin AND --bee-config, or both fields in config.toml."
475 ));
476 }
477 (None, None) => None,
478 };
479
480 let refresh = RefreshProfile::from_config(&config.ui.refresh);
487 let root_cancel = CancellationToken::new();
488 let watch = BeeWatch::start_with_profile(api.clone(), &root_cancel, refresh);
489 let health_rx = watch.health();
490
491 let market_rx = if config.economics.enable_market_tile {
495 Some(economics_oracle::spawn_poller(
496 config.economics.gnosis_rpc_url.clone(),
497 root_cancel.child_token(),
498 ))
499 } else {
500 None
501 };
502
503 let screens = build_screens(&api, &watch, market_rx);
504 let (persisted, state_path) = State::load();
509 let initial_tab = LogTab::from_kebab(&persisted.log_pane_active_tab);
510 let mut log_pane = LogPane::new(
511 log_capture::handle(),
512 initial_tab,
513 persisted.log_pane_height,
514 );
515 log_pane.set_spawn_active(supervisor.is_some());
516 if let Some(c) = log_capture::cockpit_handle() {
517 log_pane.set_cockpit_capture(c);
518 }
519
520 let bee_log_rx = supervisor.as_ref().map(|sup| {
526 let (tx, rx) = mpsc::unbounded_channel();
527 crate::bee_log_tailer::spawn(
528 sup.log_path().to_path_buf(),
529 tx,
530 root_cancel.child_token(),
531 );
532 rx
533 });
534
535 if config.metrics.enabled {
542 match config.metrics.addr.parse::<std::net::SocketAddr>() {
543 Ok(bind_addr) => {
544 let render_fn = build_metrics_render_fn(watch.clone(), log_capture::handle());
545 let cancel = root_cancel.child_token();
546 match crate::metrics_server::spawn(bind_addr, render_fn, cancel).await {
547 Ok(actual) => {
548 eprintln!(
549 "bee-tui: metrics endpoint serving /metrics on http://{actual}"
550 );
551 }
552 Err(e) => {
553 tracing::error!(
554 "metrics: failed to start endpoint on {bind_addr}: {e}"
555 );
556 }
557 }
558 }
559 Err(e) => {
560 tracing::error!(
561 "metrics: invalid [metrics].addr {:?}: {e}",
562 config.metrics.addr
563 );
564 }
565 }
566 }
567
568 let config_alerts_debounce = config.alerts.debounce_secs;
569
570 Ok(Self {
571 tick_rate,
572 frame_rate,
573 screens,
574 current_screen: 0,
575 log_pane,
576 state_path,
577 should_quit: false,
578 should_suspend: false,
579 config,
580 mode: Mode::Home,
581 last_tick_key_events: Vec::new(),
582 action_tx,
583 action_rx,
584 root_cancel,
585 api,
586 watch,
587 health_rx,
588 command_buffer: None,
589 command_suggestion_index: 0,
590 command_status: None,
591 help_visible: false,
592 quit_pending: None,
593 supervisor,
594 bee_status: BeeStatus::Running,
595 bee_log_rx,
596 cmd_status_tx,
597 cmd_status_rx,
598 durability_tx,
599 durability_rx,
600 feed_timeline_tx,
601 feed_timeline_rx,
602 watch_refs: std::collections::HashMap::new(),
603 pubsub_subs: std::collections::HashMap::new(),
604 pubsub_history,
605 pubsub_msg_tx,
606 pubsub_msg_rx,
607 alert_state: crate::alerts::AlertState::new(config_alerts_debounce),
608 })
609 }
610
611 pub async fn run(&mut self) -> color_eyre::Result<()> {
612 let mut tui = Tui::new()?
613 .tick_rate(self.tick_rate)
615 .frame_rate(self.frame_rate);
616 tui.enter()?;
617
618 let tx = self.action_tx.clone();
619 let cfg = self.config.clone();
620 let size = tui.size()?;
621 for component in self.iter_components_mut() {
622 component.register_action_handler(tx.clone())?;
623 component.register_config_handler(cfg.clone())?;
624 component.init(size)?;
625 }
626
627 let action_tx = self.action_tx.clone();
628 loop {
629 self.handle_events(&mut tui).await?;
630 self.handle_actions(&mut tui)?;
631 if self.should_suspend {
632 tui.suspend()?;
633 action_tx.send(Action::Resume)?;
634 action_tx.send(Action::ClearScreen)?;
635 tui.enter()?;
637 } else if self.should_quit {
638 tui.stop()?;
639 break;
640 }
641 }
642 self.watch.shutdown();
644 self.root_cancel.cancel();
645 let snapshot = State {
649 log_pane_height: self.log_pane.height(),
650 log_pane_active_tab: self.log_pane.active_tab().to_kebab().to_string(),
651 };
652 snapshot.save(&self.state_path);
653 if let Some(sup) = self.supervisor.take() {
657 let final_status = sup.shutdown_default().await;
658 tracing::info!("bee child exited: {}", final_status.label());
659 }
660 tui.exit()?;
661 Ok(())
662 }
663
664 async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
665 let Some(event) = tui.next_event().await else {
666 return Ok(());
667 };
668 let action_tx = self.action_tx.clone();
669 let modal_before = self.command_buffer.is_some() || self.help_visible;
676 match event {
677 Event::Quit => action_tx.send(Action::Quit)?,
678 Event::Tick => action_tx.send(Action::Tick)?,
679 Event::Render => action_tx.send(Action::Render)?,
680 Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
681 Event::Key(key) => self.handle_key_event(key)?,
682 _ => {}
683 }
684 let modal_after = self.command_buffer.is_some() || self.help_visible;
685 let propagate = !((modal_before || modal_after) && matches!(event, Event::Key(_)));
688 if propagate {
689 for component in self.iter_components_mut() {
690 if let Some(action) = component.handle_events(Some(event.clone()))? {
691 action_tx.send(action)?;
692 }
693 }
694 }
695 Ok(())
696 }
697
698 fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut dyn Component> {
703 self.screens
704 .iter_mut()
705 .map(|c| c.as_mut() as &mut dyn Component)
706 .chain(std::iter::once(&mut self.log_pane as &mut dyn Component))
707 }
708
709 fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
710 if self.command_buffer.is_some() {
714 self.handle_command_mode_key(key)?;
715 return Ok(());
716 }
717 if self.help_visible {
721 match key.code {
722 crossterm::event::KeyCode::Esc
723 | crossterm::event::KeyCode::Char('?')
724 | crossterm::event::KeyCode::Char('q') => {
725 self.help_visible = false;
726 }
727 _ => {}
728 }
729 return Ok(());
730 }
731 if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
735 self.help_visible = true;
736 return Ok(());
737 }
738 let action_tx = self.action_tx.clone();
739 if matches!(key.code, crossterm::event::KeyCode::Char(':')) {
741 self.command_buffer = Some(String::new());
742 self.command_status = None;
743 return Ok(());
744 }
745 if matches!(key.code, crossterm::event::KeyCode::Tab) {
750 if !self.screens.is_empty() {
751 self.current_screen = (self.current_screen + 1) % self.screens.len();
752 debug!(
753 "switched to screen {}",
754 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
755 );
756 }
757 return Ok(());
758 }
759 if matches!(key.code, crossterm::event::KeyCode::BackTab) {
760 if !self.screens.is_empty() {
761 let len = self.screens.len();
762 self.current_screen = (self.current_screen + len - 1) % len;
763 debug!(
764 "switched to screen {}",
765 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
766 );
767 }
768 return Ok(());
769 }
770 if matches!(key.code, crossterm::event::KeyCode::Char('['))
776 && key.modifiers == crossterm::event::KeyModifiers::NONE
777 {
778 self.log_pane.prev_tab();
779 return Ok(());
780 }
781 if matches!(key.code, crossterm::event::KeyCode::Char(']'))
782 && key.modifiers == crossterm::event::KeyModifiers::NONE
783 {
784 self.log_pane.next_tab();
785 return Ok(());
786 }
787 if matches!(key.code, crossterm::event::KeyCode::Char('+'))
788 && key.modifiers == crossterm::event::KeyModifiers::NONE
789 {
790 self.log_pane.grow();
791 return Ok(());
792 }
793 if matches!(key.code, crossterm::event::KeyCode::Char('-'))
794 && key.modifiers == crossterm::event::KeyModifiers::NONE
795 {
796 self.log_pane.shrink();
797 return Ok(());
798 }
799 if key.modifiers == crossterm::event::KeyModifiers::SHIFT {
804 match key.code {
805 crossterm::event::KeyCode::Up => {
806 self.log_pane.scroll_up(1);
807 return Ok(());
808 }
809 crossterm::event::KeyCode::Down => {
810 self.log_pane.scroll_down(1);
811 return Ok(());
812 }
813 crossterm::event::KeyCode::PageUp => {
814 self.log_pane.scroll_up(10);
815 return Ok(());
816 }
817 crossterm::event::KeyCode::PageDown => {
818 self.log_pane.scroll_down(10);
819 return Ok(());
820 }
821 crossterm::event::KeyCode::End => {
822 self.log_pane.resume_tail();
823 return Ok(());
824 }
825 crossterm::event::KeyCode::Left => {
831 self.log_pane.scroll_left(8);
832 return Ok(());
833 }
834 crossterm::event::KeyCode::Right => {
835 self.log_pane.scroll_right(8);
836 return Ok(());
837 }
838 _ => {}
839 }
840 }
841 if matches!(key.code, crossterm::event::KeyCode::Char('q'))
847 && key.modifiers == crossterm::event::KeyModifiers::NONE
848 {
849 match resolve_quit_press(self.quit_pending, Instant::now(), QUIT_CONFIRM_WINDOW) {
850 QuitResolution::Confirm => {
851 self.quit_pending = None;
852 self.action_tx.send(Action::Quit)?;
853 }
854 QuitResolution::Pending => {
855 self.quit_pending = Some(Instant::now());
856 self.command_status = Some(CommandStatus::Info(
857 "press q again to quit (Esc cancels)".into(),
858 ));
859 }
860 }
861 return Ok(());
862 }
863 if self.quit_pending.is_some() {
867 self.quit_pending = None;
868 }
869 let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
870 return Ok(());
871 };
872 match keymap.get(&vec![key]) {
873 Some(action) => {
874 info!("Got action: {action:?}");
875 action_tx.send(action.clone())?;
876 }
877 _ => {
878 self.last_tick_key_events.push(key);
881
882 if let Some(action) = keymap.get(&self.last_tick_key_events) {
884 info!("Got action: {action:?}");
885 action_tx.send(action.clone())?;
886 }
887 }
888 }
889 Ok(())
890 }
891
892 fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
893 use crossterm::event::KeyCode;
894 let buf = match self.command_buffer.as_mut() {
895 Some(b) => b,
896 None => return Ok(()),
897 };
898 match key.code {
899 KeyCode::Esc => {
900 self.command_buffer = None;
902 self.command_suggestion_index = 0;
903 }
904 KeyCode::Enter => {
905 let line = std::mem::take(buf);
906 self.command_buffer = None;
907 self.command_suggestion_index = 0;
908 self.execute_command(&line)?;
909 }
910 KeyCode::Up => {
911 self.command_suggestion_index = self.command_suggestion_index.saturating_sub(1);
914 }
915 KeyCode::Down => {
916 let n = filter_command_suggestions(buf, KNOWN_COMMANDS).len();
917 if n > 0 && self.command_suggestion_index + 1 < n {
918 self.command_suggestion_index += 1;
919 }
920 }
921 KeyCode::Tab => {
922 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
926 if let Some((name, _)) = matches.get(self.command_suggestion_index) {
927 let rest = buf
928 .split_once(char::is_whitespace)
929 .map(|(_, tail)| tail)
930 .unwrap_or("");
931 let new = if rest.is_empty() {
932 format!("{name} ")
933 } else {
934 format!("{name} {rest}")
935 };
936 buf.clear();
937 buf.push_str(&new);
938 self.command_suggestion_index = 0;
939 }
940 }
941 KeyCode::Backspace => {
942 buf.pop();
943 self.command_suggestion_index = 0;
944 }
945 KeyCode::Char(c) => {
946 buf.push(c);
947 self.command_suggestion_index = 0;
948 }
949 _ => {}
950 }
951 Ok(())
952 }
953
954 fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
957 let trimmed = line.trim();
958 if trimmed.is_empty() {
959 return Ok(());
960 }
961 let head = trimmed.split_whitespace().next().unwrap_or("");
962 match head {
963 "q" | "quit" => {
964 self.action_tx.send(Action::Quit)?;
965 self.command_status = Some(CommandStatus::Info("quitting".into()));
966 }
967 "diagnose" | "diag" => {
968 let pprof_secs = parse_pprof_arg(trimmed);
969 if let Some(secs) = pprof_secs {
970 self.command_status = Some(self.start_diagnose_with_pprof(secs));
971 } else {
972 self.command_status = Some(match self.export_diagnostic_bundle() {
973 Ok(path) => CommandStatus::Info(format!(
974 "diagnostic bundle exported to {}",
975 path.display()
976 )),
977 Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
978 });
979 }
980 }
981 "pins-check" => {
982 self.command_status = Some(match self.start_pins_check() {
988 Ok(path) => CommandStatus::Info(format!(
989 "pins integrity check running → {} (tail to watch progress)",
990 path.display()
991 )),
992 Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
993 });
994 }
995 "loggers" => {
996 self.command_status = Some(match self.start_loggers_dump() {
997 Ok(path) => CommandStatus::Info(format!(
998 "loggers snapshot writing → {} (open when ready)",
999 path.display()
1000 )),
1001 Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
1002 });
1003 }
1004 "set-logger" => {
1005 let mut parts = trimmed.split_whitespace();
1006 let _ = parts.next(); let expr = parts.next().unwrap_or("");
1008 let level = parts.next().unwrap_or("");
1009 if expr.is_empty() || level.is_empty() {
1010 self.command_status = Some(CommandStatus::Err(
1011 "usage: :set-logger <expr> <level> (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
1012 .into(),
1013 ));
1014 return Ok(());
1015 }
1016 self.start_set_logger(expr.to_string(), level.to_string());
1017 self.command_status = Some(CommandStatus::Info(format!(
1018 "set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
1019 )));
1020 }
1021 "topup-preview" => {
1022 self.command_status = Some(self.run_topup_preview(trimmed));
1023 }
1024 "dilute-preview" => {
1025 self.command_status = Some(self.run_dilute_preview(trimmed));
1026 }
1027 "extend-preview" => {
1028 self.command_status = Some(self.run_extend_preview(trimmed));
1029 }
1030 "buy-preview" => {
1031 self.command_status = Some(self.run_buy_preview(trimmed));
1032 }
1033 "buy-suggest" => {
1034 self.command_status = Some(self.run_buy_suggest(trimmed));
1035 }
1036 "plan-batch" => {
1037 self.command_status = Some(self.run_plan_batch(trimmed));
1038 }
1039 "check-version" => {
1040 self.command_status = Some(self.run_check_version());
1041 }
1042 "config-doctor" => {
1043 self.command_status = Some(self.run_config_doctor());
1044 }
1045 "price" => {
1046 self.command_status = Some(self.run_price());
1047 }
1048 "basefee" => {
1049 self.command_status = Some(self.run_basefee());
1050 }
1051 "probe-upload" => {
1052 self.command_status = Some(self.run_probe_upload(trimmed));
1053 }
1054 "upload-file" => {
1055 self.command_status = Some(self.run_upload_file(trimmed));
1056 }
1057 "upload-collection" => {
1058 self.command_status = Some(self.run_upload_collection(trimmed));
1059 }
1060 "feed-probe" => {
1061 self.command_status = Some(self.run_feed_probe(trimmed));
1062 }
1063 "feed-timeline" => {
1064 self.command_status = Some(self.run_feed_timeline(trimmed));
1065 }
1066 "hash" => {
1067 self.command_status = Some(self.run_hash(trimmed));
1068 }
1069 "cid" => {
1070 self.command_status = Some(self.run_cid(trimmed));
1071 }
1072 "depth-table" => {
1073 self.command_status = Some(self.run_depth_table());
1074 }
1075 "gsoc-mine" => {
1076 self.command_status = Some(self.run_gsoc_mine(trimmed));
1077 }
1078 "pss-target" => {
1079 self.command_status = Some(self.run_pss_target(trimmed));
1080 }
1081 "manifest" => {
1082 self.command_status = Some(self.run_manifest(trimmed));
1083 }
1084 "inspect" => {
1085 self.command_status = Some(self.run_inspect(trimmed));
1086 }
1087 "durability-check" => {
1088 self.command_status = Some(self.run_durability_check(trimmed));
1089 }
1090 "grantees-list" => {
1091 self.command_status = Some(self.run_grantees_list(trimmed));
1092 }
1093 "watch-ref" => {
1094 self.command_status = Some(self.run_watch_ref(trimmed));
1095 }
1096 "watch-ref-stop" => {
1097 self.command_status = Some(self.run_watch_ref_stop(trimmed));
1098 }
1099 "pubsub-pss" => {
1100 self.command_status = Some(self.run_pubsub_pss(trimmed));
1101 }
1102 "pubsub-gsoc" => {
1103 self.command_status = Some(self.run_pubsub_gsoc(trimmed));
1104 }
1105 "pubsub-stop" => {
1106 self.command_status = Some(self.run_pubsub_stop(trimmed));
1107 }
1108 "pubsub-filter" => {
1109 self.command_status = Some(self.run_pubsub_filter(trimmed));
1110 }
1111 "pubsub-filter-clear" => {
1112 self.command_status = Some(self.run_pubsub_filter_clear());
1113 }
1114 "context" | "ctx" => {
1115 let target = trimmed.split_whitespace().nth(1).unwrap_or("");
1116 if target.is_empty() {
1117 let known: Vec<String> =
1118 self.config.nodes.iter().map(|n| n.name.clone()).collect();
1119 self.command_status = Some(CommandStatus::Err(format!(
1120 "usage: :context <name> (known: {})",
1121 known.join(", ")
1122 )));
1123 return Ok(());
1124 }
1125 self.command_status = Some(match self.switch_context(target) {
1126 Ok(()) => CommandStatus::Info(format!(
1127 "switched to context {target} ({})",
1128 self.api.url
1129 )),
1130 Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
1131 });
1132 }
1133 screen
1134 if SCREEN_NAMES
1135 .iter()
1136 .any(|name| name.eq_ignore_ascii_case(screen)) =>
1137 {
1138 if let Some(idx) = SCREEN_NAMES
1139 .iter()
1140 .position(|name| name.eq_ignore_ascii_case(screen))
1141 {
1142 self.current_screen = idx;
1143 self.command_status =
1144 Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
1145 }
1146 }
1147 other => {
1148 self.command_status = Some(CommandStatus::Err(format!(
1149 "unknown command: {other:?} (try :health, :stamps, :swap, :lottery, :peers, :network, :warmup, :api, :tags, :pins, :manifest, :inspect, :diagnose, :pins-check, :loggers, :set-logger, :topup-preview, :dilute-preview, :extend-preview, :buy-preview, :buy-suggest, :plan-batch, :probe-upload, :upload-file, :upload-collection, :feed-probe, :feed-timeline, :watch-ref, :watch-ref-stop, :pubsub-pss, :pubsub-gsoc, :pubsub-stop, :pubsub-filter, :pubsub-filter-clear, :grantees-list, :hash, :cid, :depth-table, :gsoc-mine, :pss-target, :context, :quit)"
1150 )));
1151 }
1152 }
1153 Ok(())
1154 }
1155
1156 fn run_topup_preview(&self, line: &str) -> CommandStatus {
1160 let parts: Vec<&str> = line.split_whitespace().collect();
1161 let (prefix, amount_str) = match parts.as_slice() {
1162 [_, prefix, amount, ..] => (*prefix, *amount),
1163 _ => {
1164 return CommandStatus::Err(
1165 "usage: :topup-preview <batch-prefix> <amount-plur-per-chunk>".into(),
1166 );
1167 }
1168 };
1169 let chain = match self.health_rx.borrow().chain_state.clone() {
1170 Some(c) => c,
1171 None => return CommandStatus::Err("chain state not loaded yet".into()),
1172 };
1173 let stamps = self.watch.stamps().borrow().clone();
1174 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1175 Ok(b) => b.clone(),
1176 Err(e) => return CommandStatus::Err(e),
1177 };
1178 let amount = match stamp_preview::parse_plur_amount(amount_str) {
1179 Ok(a) => a,
1180 Err(e) => return CommandStatus::Err(e),
1181 };
1182 match stamp_preview::topup_preview(&batch, amount, &chain) {
1183 Ok(p) => CommandStatus::Info(p.summary()),
1184 Err(e) => CommandStatus::Err(e),
1185 }
1186 }
1187
1188 fn run_dilute_preview(&self, line: &str) -> CommandStatus {
1192 let parts: Vec<&str> = line.split_whitespace().collect();
1193 let (prefix, depth_str) = match parts.as_slice() {
1194 [_, prefix, depth, ..] => (*prefix, *depth),
1195 _ => {
1196 return CommandStatus::Err(
1197 "usage: :dilute-preview <batch-prefix> <new-depth>".into(),
1198 );
1199 }
1200 };
1201 let new_depth: u8 = match depth_str.parse() {
1202 Ok(d) => d,
1203 Err(_) => {
1204 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1205 }
1206 };
1207 let stamps = self.watch.stamps().borrow().clone();
1208 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1209 Ok(b) => b.clone(),
1210 Err(e) => return CommandStatus::Err(e),
1211 };
1212 match stamp_preview::dilute_preview(&batch, new_depth) {
1213 Ok(p) => CommandStatus::Info(p.summary()),
1214 Err(e) => CommandStatus::Err(e),
1215 }
1216 }
1217
1218 fn run_extend_preview(&self, line: &str) -> CommandStatus {
1221 let parts: Vec<&str> = line.split_whitespace().collect();
1222 let (prefix, duration_str) = match parts.as_slice() {
1223 [_, prefix, duration, ..] => (*prefix, *duration),
1224 _ => {
1225 return CommandStatus::Err(
1226 "usage: :extend-preview <batch-prefix> <duration> (e.g. 30d, 12h, 90m, 45s, or plain seconds)".into(),
1227 );
1228 }
1229 };
1230 let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1231 Ok(s) => s,
1232 Err(e) => return CommandStatus::Err(e),
1233 };
1234 let chain = match self.health_rx.borrow().chain_state.clone() {
1235 Some(c) => c,
1236 None => return CommandStatus::Err("chain state not loaded yet".into()),
1237 };
1238 let stamps = self.watch.stamps().borrow().clone();
1239 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1240 Ok(b) => b.clone(),
1241 Err(e) => return CommandStatus::Err(e),
1242 };
1243 match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
1244 Ok(p) => CommandStatus::Info(p.summary()),
1245 Err(e) => CommandStatus::Err(e),
1246 }
1247 }
1248
1249 fn run_probe_upload(&self, line: &str) -> CommandStatus {
1261 let parts: Vec<&str> = line.split_whitespace().collect();
1262 let prefix = match parts.as_slice() {
1263 [_, prefix, ..] => *prefix,
1264 _ => {
1265 return CommandStatus::Err(
1266 "usage: :probe-upload <batch-prefix> (uploads one synthetic 4 KiB chunk)"
1267 .into(),
1268 );
1269 }
1270 };
1271 let stamps = self.watch.stamps().borrow().clone();
1272 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1273 Ok(b) => b.clone(),
1274 Err(e) => return CommandStatus::Err(e),
1275 };
1276 if !batch.usable {
1277 return CommandStatus::Err(format!(
1278 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1279 short_hex(&batch.batch_id.to_hex(), 8),
1280 ));
1281 }
1282 if batch.batch_ttl <= 0 {
1283 return CommandStatus::Err(format!(
1284 "batch {} is expired — pick another",
1285 short_hex(&batch.batch_id.to_hex(), 8),
1286 ));
1287 }
1288
1289 let api = self.api.clone();
1290 let tx = self.cmd_status_tx.clone();
1291 let batch_id = batch.batch_id;
1292 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1293 let task_short = batch_short.clone();
1294 tokio::spawn(async move {
1295 let chunk = build_synthetic_probe_chunk();
1296 let started = Instant::now();
1297 let result = api.bee().file().upload_chunk(&batch_id, chunk, None).await;
1298 let elapsed_ms = started.elapsed().as_millis();
1299 let status = match result {
1300 Ok(res) => CommandStatus::Info(format!(
1301 "probe-upload OK in {elapsed_ms}ms — batch {task_short}, ref {}",
1302 short_hex(&res.reference.to_hex(), 8),
1303 )),
1304 Err(e) => CommandStatus::Err(format!(
1305 "probe-upload FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1306 )),
1307 };
1308 let _ = tx.send(status);
1309 });
1310
1311 CommandStatus::Info(format!(
1312 "probe-upload to batch {batch_short} in flight — result will replace this line"
1313 ))
1314 }
1315
1316 fn run_upload_file(&self, line: &str) -> CommandStatus {
1324 let parts: Vec<&str> = line.split_whitespace().collect();
1325 let (path_str, prefix) = match parts.as_slice() {
1326 [_, p, b, ..] => (*p, *b),
1327 _ => {
1328 return CommandStatus::Err("usage: :upload-file <path> <batch-prefix>".into());
1329 }
1330 };
1331 let path = std::path::PathBuf::from(path_str);
1332 let meta = match std::fs::metadata(&path) {
1333 Ok(m) => m,
1334 Err(e) => return CommandStatus::Err(format!("stat {path_str}: {e}")),
1335 };
1336 if meta.is_dir() {
1337 return CommandStatus::Err(format!(
1338 "{path_str} is a directory — :upload-file is single-file only (collection upload coming in a later release)"
1339 ));
1340 }
1341 const MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
1342 if meta.len() > MAX_FILE_BYTES {
1343 return CommandStatus::Err(format!(
1344 "{path_str} is {} — over the {}-MiB cockpit ceiling; use swarm-cli for larger uploads",
1345 meta.len(),
1346 MAX_FILE_BYTES / (1024 * 1024),
1347 ));
1348 }
1349 let stamps = self.watch.stamps().borrow().clone();
1350 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1351 Ok(b) => b.clone(),
1352 Err(e) => return CommandStatus::Err(e),
1353 };
1354 if !batch.usable {
1355 return CommandStatus::Err(format!(
1356 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1357 short_hex(&batch.batch_id.to_hex(), 8),
1358 ));
1359 }
1360 if batch.batch_ttl <= 0 {
1361 return CommandStatus::Err(format!(
1362 "batch {} is expired — pick another",
1363 short_hex(&batch.batch_id.to_hex(), 8),
1364 ));
1365 }
1366
1367 let api = self.api.clone();
1368 let tx = self.cmd_status_tx.clone();
1369 let batch_id = batch.batch_id;
1370 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1371 let task_short = batch_short.clone();
1372 let file_size = meta.len();
1373 let name = path
1374 .file_name()
1375 .and_then(|n| n.to_str())
1376 .unwrap_or("")
1377 .to_string();
1378 let content_type = guess_content_type(&path);
1379 tokio::spawn(async move {
1380 let data = match tokio::fs::read(&path).await {
1381 Ok(b) => b,
1382 Err(e) => {
1383 let _ = tx.send(CommandStatus::Err(format!("read {}: {e}", path.display())));
1384 return;
1385 }
1386 };
1387 let started = Instant::now();
1388 let result = api
1389 .bee()
1390 .file()
1391 .upload_file(&batch_id, data, &name, &content_type, None)
1392 .await;
1393 let elapsed_ms = started.elapsed().as_millis();
1394 let status = match result {
1395 Ok(res) => CommandStatus::Info(format!(
1396 "upload-file OK in {elapsed_ms}ms — {file_size}B → ref {} (batch {task_short})",
1397 res.reference.to_hex(),
1398 )),
1399 Err(e) => CommandStatus::Err(format!(
1400 "upload-file FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1401 )),
1402 };
1403 let _ = tx.send(status);
1404 });
1405
1406 CommandStatus::Info(format!(
1407 "upload-file ({file_size}B) to batch {batch_short} in flight — result will replace this line"
1408 ))
1409 }
1410
1411 fn run_upload_collection(&self, line: &str) -> CommandStatus {
1419 let parts: Vec<&str> = line.split_whitespace().collect();
1420 let (dir_str, prefix) = match parts.as_slice() {
1421 [_, d, b, ..] => (*d, *b),
1422 _ => {
1423 return CommandStatus::Err("usage: :upload-collection <dir> <batch-prefix>".into());
1424 }
1425 };
1426 let dir = std::path::PathBuf::from(dir_str);
1427 let walked = match crate::uploads::walk_dir(&dir) {
1428 Ok(w) => w,
1429 Err(e) => return CommandStatus::Err(format!("walk {dir_str}: {e}")),
1430 };
1431 if walked.entries.is_empty() {
1432 return CommandStatus::Err(format!(
1433 "{dir_str} contains no uploadable files (after skipping hidden + symlinks)"
1434 ));
1435 }
1436 let stamps = self.watch.stamps().borrow().clone();
1437 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1438 Ok(b) => b.clone(),
1439 Err(e) => return CommandStatus::Err(e),
1440 };
1441 if !batch.usable {
1442 return CommandStatus::Err(format!(
1443 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1444 short_hex(&batch.batch_id.to_hex(), 8),
1445 ));
1446 }
1447 if batch.batch_ttl <= 0 {
1448 return CommandStatus::Err(format!(
1449 "batch {} is expired — pick another",
1450 short_hex(&batch.batch_id.to_hex(), 8),
1451 ));
1452 }
1453
1454 let api = self.api.clone();
1455 let tx = self.cmd_status_tx.clone();
1456 let batch_id = batch.batch_id;
1457 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1458 let task_short = batch_short.clone();
1459 let total_bytes = walked.total_bytes;
1460 let entry_count = walked.entries.len();
1461 let entries = walked.entries;
1462 let default_index = walked.default_index.clone();
1463 let dir_str_owned = dir_str.to_string();
1464 let default_index_for_msg = default_index.clone();
1465 tokio::spawn(async move {
1466 let opts = bee::api::CollectionUploadOptions {
1467 index_document: default_index,
1468 ..Default::default()
1469 };
1470 let started = Instant::now();
1471 let result = api
1472 .bee()
1473 .file()
1474 .upload_collection_entries(&batch_id, &entries, Some(&opts))
1475 .await;
1476 let elapsed_ms = started.elapsed().as_millis();
1477 let status = match result {
1478 Ok(res) => {
1479 let idx = default_index_for_msg
1480 .as_deref()
1481 .map(|i| format!(" · index={i}"))
1482 .unwrap_or_default();
1483 CommandStatus::Info(format!(
1484 "upload-collection OK in {elapsed_ms}ms — {entry_count} files, {total_bytes}B → ref {} (batch {task_short}){idx}",
1485 res.reference.to_hex(),
1486 ))
1487 }
1488 Err(e) => CommandStatus::Err(format!(
1489 "upload-collection FAILED after {elapsed_ms}ms — {dir_str_owned} → batch {task_short}: {e}"
1490 )),
1491 };
1492 let _ = tx.send(status);
1493 });
1494
1495 let idx_note = walked
1496 .default_index
1497 .as_deref()
1498 .map(|i| format!(" · default index={i}"))
1499 .unwrap_or_default();
1500 CommandStatus::Info(format!(
1501 "upload-collection {entry_count} files ({total_bytes}B){idx_note} to batch {batch_short} in flight — result will replace this line"
1502 ))
1503 }
1504
1505 fn run_feed_probe(&self, line: &str) -> CommandStatus {
1511 let parts: Vec<&str> = line.split_whitespace().collect();
1512 let (owner_str, topic_str) = match parts.as_slice() {
1513 [_, o, t, ..] => (*o, *t),
1514 _ => {
1515 return CommandStatus::Err(
1516 "usage: :feed-probe <owner> <topic> (topic = 64-hex or arbitrary string)"
1517 .into(),
1518 );
1519 }
1520 };
1521 let parsed = match crate::feed_probe::parse_args(owner_str, topic_str) {
1522 Ok(p) => p,
1523 Err(e) => return CommandStatus::Err(e),
1524 };
1525 let owner_short = short_hex(&parsed.owner.to_hex(), 8);
1526 let api = self.api.clone();
1527 let tx = self.cmd_status_tx.clone();
1528 tokio::spawn(async move {
1529 let started = Instant::now();
1530 let status = match crate::feed_probe::probe(api, parsed).await {
1531 Ok(r) => CommandStatus::Info(format!(
1532 "{} ({}ms)",
1533 r.summary(),
1534 started.elapsed().as_millis()
1535 )),
1536 Err(e) => CommandStatus::Err(format!("feed-probe failed: {e}")),
1537 };
1538 let _ = tx.send(status);
1539 });
1540 CommandStatus::Info(format!(
1541 "feed-probe owner={owner_short} in flight — result will replace this line (first lookup can take 30-60s)"
1542 ))
1543 }
1544
1545 fn run_feed_timeline(&mut self, line: &str) -> CommandStatus {
1552 let parts: Vec<&str> = line.split_whitespace().collect();
1553 let (owner_str, topic_str, n_arg) = match parts.as_slice() {
1554 [_, o, t] => (*o, *t, None),
1555 [_, o, t, n, ..] => (*o, *t, Some(*n)),
1556 _ => {
1557 return CommandStatus::Err(
1558 "usage: :feed-timeline <owner> <topic> [N] (default 50, hard max 1000)".into(),
1559 );
1560 }
1561 };
1562 let parsed = match crate::feed_probe::parse_args(owner_str, topic_str) {
1563 Ok(p) => p,
1564 Err(e) => return CommandStatus::Err(e),
1565 };
1566 let max_entries = match n_arg {
1567 None => crate::feed_timeline::DEFAULT_MAX_ENTRIES,
1568 Some(s) => match s.parse::<u64>() {
1569 Ok(n) if n > 0 => n,
1570 _ => return CommandStatus::Err(format!("invalid N: {s:?}")),
1571 },
1572 };
1573 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "FeedTimeline") {
1577 self.current_screen = idx;
1578 if let Some(ft) = self
1579 .screens
1580 .get_mut(idx)
1581 .and_then(|s| s.as_any_mut())
1582 .and_then(|a| a.downcast_mut::<FeedTimeline>())
1583 {
1584 let label = format!(
1585 "owner=0x{} · topic={} · N={max_entries}",
1586 short_hex(&parsed.owner.to_hex(), 8),
1587 short_hex(&parsed.topic.to_hex(), 8),
1588 );
1589 ft.set_loading(label);
1590 }
1591 }
1592 let api = self.api.clone();
1593 let tx = self.feed_timeline_tx.clone();
1594 tokio::spawn(async move {
1595 let msg = match crate::feed_timeline::walk(api, parsed.owner, parsed.topic, max_entries)
1596 .await
1597 {
1598 Ok(t) => FeedTimelineMessage::Loaded(t),
1599 Err(e) => FeedTimelineMessage::Failed(e),
1600 };
1601 let _ = tx.send(msg);
1602 });
1603 CommandStatus::Info(format!(
1604 "feed-timeline N={max_entries} in flight — switching to S14 (first lookup can take 30-60s)"
1605 ))
1606 }
1607
1608 fn run_hash(&self, line: &str) -> CommandStatus {
1613 let parts: Vec<&str> = line.split_whitespace().collect();
1614 let path = match parts.as_slice() {
1615 [_, p, ..] => *p,
1616 _ => {
1617 return CommandStatus::Err(
1618 "usage: :hash <path> (file or directory; computed locally)".into(),
1619 );
1620 }
1621 };
1622 match utility_verbs::hash_path(path) {
1623 Ok(r) => CommandStatus::Info(format!("hash {path}: {r}")),
1624 Err(e) => CommandStatus::Err(format!("hash failed: {e}")),
1625 }
1626 }
1627
1628 fn run_cid(&self, line: &str) -> CommandStatus {
1632 let parts: Vec<&str> = line.split_whitespace().collect();
1633 let (ref_hex, kind_arg) = match parts.as_slice() {
1634 [_, r, k, ..] => (*r, Some(*k)),
1635 [_, r] => (*r, None),
1636 _ => {
1637 return CommandStatus::Err(
1638 "usage: :cid <ref> [manifest|feed] (default manifest)".into(),
1639 );
1640 }
1641 };
1642 let kind = match utility_verbs::parse_cid_kind(kind_arg) {
1643 Ok(k) => k,
1644 Err(e) => return CommandStatus::Err(e),
1645 };
1646 match utility_verbs::cid_for_ref(ref_hex, kind) {
1647 Ok(cid) => CommandStatus::Info(format!("cid: {cid}")),
1648 Err(e) => CommandStatus::Err(format!("cid failed: {e}")),
1649 }
1650 }
1651
1652 fn run_depth_table(&self) -> CommandStatus {
1657 let body = utility_verbs::depth_table();
1658 let path = std::env::temp_dir().join("bee-tui-depth-table.txt");
1659 match std::fs::write(&path, &body) {
1660 Ok(()) => CommandStatus::Info(format!("depth table → {}", path.display())),
1661 Err(e) => CommandStatus::Err(format!("depth-table write failed: {e}")),
1662 }
1663 }
1664
1665 fn run_gsoc_mine(&self, line: &str) -> CommandStatus {
1670 let parts: Vec<&str> = line.split_whitespace().collect();
1671 let (overlay, ident) = match parts.as_slice() {
1672 [_, o, i, ..] => (*o, *i),
1673 _ => {
1674 return CommandStatus::Err(
1675 "usage: :gsoc-mine <overlay-hex> <identifier> (CPU work, no network)".into(),
1676 );
1677 }
1678 };
1679 match utility_verbs::gsoc_mine_for(overlay, ident) {
1680 Ok(out) => CommandStatus::Info(out.replace('\n', " · ")),
1681 Err(e) => CommandStatus::Err(format!("gsoc-mine failed: {e}")),
1682 }
1683 }
1684
1685 fn run_manifest(&mut self, line: &str) -> CommandStatus {
1689 let parts: Vec<&str> = line.split_whitespace().collect();
1690 let ref_arg = match parts.as_slice() {
1691 [_, r, ..] => *r,
1692 _ => {
1693 return CommandStatus::Err(
1694 "usage: :manifest <ref> (32-byte hex reference)".into(),
1695 );
1696 }
1697 };
1698 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1699 Ok(r) => r,
1700 Err(e) => return CommandStatus::Err(format!("manifest: bad ref: {e}")),
1701 };
1702 let idx = match SCREEN_NAMES.iter().position(|n| *n == "Manifest") {
1705 Some(i) => i,
1706 None => {
1707 return CommandStatus::Err("internal: Manifest screen not registered".into());
1708 }
1709 };
1710 let screen = self
1711 .screens
1712 .get_mut(idx)
1713 .and_then(|s| s.as_any_mut())
1714 .and_then(|a| a.downcast_mut::<Manifest>());
1715 let Some(manifest) = screen else {
1716 return CommandStatus::Err("internal: failed to access Manifest screen".into());
1717 };
1718 manifest.load(reference);
1719 self.current_screen = idx;
1720 CommandStatus::Info(format!("loading manifest {}", short_hex(ref_arg, 8)))
1721 }
1722
1723 fn run_inspect(&self, line: &str) -> CommandStatus {
1730 let parts: Vec<&str> = line.split_whitespace().collect();
1731 let ref_arg = match parts.as_slice() {
1732 [_, r, ..] => *r,
1733 _ => {
1734 return CommandStatus::Err("usage: :inspect <ref> (32-byte hex reference)".into());
1735 }
1736 };
1737 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1738 Ok(r) => r,
1739 Err(e) => return CommandStatus::Err(format!("inspect: bad ref: {e}")),
1740 };
1741 let api = self.api.clone();
1742 let tx = self.cmd_status_tx.clone();
1743 let label = short_hex(ref_arg, 8);
1744 let label_for_task = label.clone();
1745 tokio::spawn(async move {
1746 let result = manifest_walker::inspect(api, reference).await;
1747 let status = match result {
1748 InspectResult::Manifest { node, bytes_len } => CommandStatus::Info(format!(
1749 "inspect {label_for_task}: manifest · {bytes_len} bytes · {} forks (jump to :manifest {label_for_task})",
1750 node.forks.len(),
1751 )),
1752 InspectResult::RawChunk { bytes_len } => CommandStatus::Info(format!(
1753 "inspect {label_for_task}: raw chunk · {bytes_len} bytes · not a manifest"
1754 )),
1755 InspectResult::Error(e) => {
1756 CommandStatus::Err(format!("inspect {label_for_task} failed: {e}"))
1757 }
1758 };
1759 let _ = tx.send(status);
1760 });
1761 CommandStatus::Info(format!(
1762 "inspecting {label} — result will replace this line"
1763 ))
1764 }
1765
1766 fn run_grantees_list(&self, line: &str) -> CommandStatus {
1781 let parts: Vec<&str> = line.split_whitespace().collect();
1782 let ref_arg = match parts.as_slice() {
1783 [_, r, ..] => *r,
1784 _ => return CommandStatus::Err("usage: :grantees-list <ref>".into()),
1785 };
1786 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1787 Ok(r) => r,
1788 Err(e) => return CommandStatus::Err(format!("grantees-list: bad ref: {e}")),
1789 };
1790 let api = self.api.clone();
1791 let tx = self.cmd_status_tx.clone();
1792 let label = short_hex(ref_arg, 8);
1793 let label_for_task = label.clone();
1794 tokio::spawn(async move {
1795 let status = match api.bee().api().get_grantees(&reference).await {
1796 Ok(list) => {
1797 if list.is_empty() {
1798 CommandStatus::Info(format!(
1799 "grantees-list {label_for_task}: no grantees registered"
1800 ))
1801 } else {
1802 let preview: Vec<String> =
1803 list.iter().take(3).map(|p| short_hex(p, 12)).collect();
1804 let suffix = if list.len() > 3 {
1805 format!(" (+{} more)", list.len() - 3)
1806 } else {
1807 String::new()
1808 };
1809 CommandStatus::Info(format!(
1810 "grantees-list {label_for_task}: {} grantee(s) — {}{suffix}",
1811 list.len(),
1812 preview.join(", "),
1813 ))
1814 }
1815 }
1816 Err(e) => CommandStatus::Err(format!("grantees-list {label_for_task} failed: {e}")),
1817 };
1818 let _ = tx.send(status);
1819 });
1820 CommandStatus::Info(format!(
1821 "grantees-list {label} in flight — result will replace this line"
1822 ))
1823 }
1824
1825 fn run_durability_check(&mut self, line: &str) -> CommandStatus {
1826 let parts: Vec<&str> = line.split_whitespace().collect();
1827 let ref_arg = match parts.as_slice() {
1828 [_, r, ..] => *r,
1829 _ => {
1830 return CommandStatus::Err(
1831 "usage: :durability-check <ref> (32-byte hex reference)".into(),
1832 );
1833 }
1834 };
1835 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1836 Ok(r) => r,
1837 Err(e) => {
1838 return CommandStatus::Err(format!("durability-check: bad ref: {e}"));
1839 }
1840 };
1841 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Watchlist") {
1844 self.current_screen = idx;
1845 }
1846 let api = self.api.clone();
1847 let tx = self.cmd_status_tx.clone();
1848 let watchlist_tx = self.durability_tx.clone();
1849 let label = short_hex(ref_arg, 8);
1850 let label_for_task = label.clone();
1851 let opts = self.durability_check_options();
1852 tokio::spawn(async move {
1853 let result = durability::check_with_options(api, reference, opts).await;
1854 let summary = result.summary();
1855 let _ = watchlist_tx.send(result);
1856 let _ = tx.send(if summary.contains("UNHEALTHY") {
1857 CommandStatus::Err(summary)
1858 } else {
1859 CommandStatus::Info(summary)
1860 });
1861 });
1862 CommandStatus::Info(format!(
1863 "durability-check {label_for_task} in flight — see S13 Watchlist for the running history"
1864 ))
1865 }
1866
1867 fn durability_check_options(&self) -> durability::CheckOptions {
1872 durability::CheckOptions {
1873 bmt_verify: true,
1874 swarmscan_url: if self.config.durability.swarmscan_check {
1875 Some(self.config.durability.swarmscan_url.clone())
1876 } else {
1877 None
1878 },
1879 }
1880 }
1881
1882 fn run_watch_ref(&mut self, line: &str) -> CommandStatus {
1892 let parts: Vec<&str> = line.split_whitespace().collect();
1893 let (ref_arg, interval_arg) = match parts.as_slice() {
1894 [_, r] => (*r, None),
1895 [_, r, i, ..] => (*r, Some(*i)),
1896 _ => {
1897 return CommandStatus::Err(
1898 "usage: :watch-ref <ref> [interval-secs] (default 60s)".into(),
1899 );
1900 }
1901 };
1902 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1903 Ok(r) => r,
1904 Err(e) => return CommandStatus::Err(format!("watch-ref: bad ref: {e}")),
1905 };
1906 let interval_secs = match interval_arg {
1907 None => 60u64,
1908 Some(s) => match s.parse::<u64>() {
1909 Ok(n) if (10..=86_400).contains(&n) => n,
1910 Ok(n) => {
1911 return CommandStatus::Err(format!(
1912 "watch-ref: interval {n}s out of range (10..=86400)"
1913 ));
1914 }
1915 Err(_) => return CommandStatus::Err(format!("watch-ref: invalid interval: {s:?}")),
1916 },
1917 };
1918 let key = reference.to_hex();
1919 if let Some(prev) = self.watch_refs.remove(&key) {
1922 prev.cancel();
1923 }
1924 let cancel = self.root_cancel.child_token();
1925 self.watch_refs.insert(key.clone(), cancel.clone());
1926
1927 let api = self.api.clone();
1928 let watchlist_tx = self.durability_tx.clone();
1929 let label = short_hex(ref_arg, 8);
1930 let label_for_task = label.clone();
1931 let opts = self.durability_check_options();
1932 tokio::spawn(async move {
1933 let interval = std::time::Duration::from_secs(interval_secs);
1934 loop {
1935 let result =
1936 durability::check_with_options(api.clone(), reference.clone(), opts.clone())
1937 .await;
1938 let _ = watchlist_tx.send(result);
1939 tokio::select! {
1940 _ = tokio::time::sleep(interval) => {}
1941 _ = cancel.cancelled() => return,
1942 }
1943 }
1944 });
1945
1946 CommandStatus::Info(format!(
1947 "watch-ref {label_for_task} started — re-checking every {interval_secs}s; results in S13 Watchlist"
1948 ))
1949 }
1950
1951 fn run_watch_ref_stop(&mut self, line: &str) -> CommandStatus {
1958 let parts: Vec<&str> = line.split_whitespace().collect();
1959 match parts.as_slice() {
1960 [_] => {
1961 let n = self.watch_refs.len();
1962 for (_, c) in self.watch_refs.drain() {
1963 c.cancel();
1964 }
1965 CommandStatus::Info(format!("watch-ref-stop: cancelled {n} active daemon(s)"))
1966 }
1967 [_, r, ..] => {
1968 let reference = match bee::swarm::Reference::from_hex(r.trim()) {
1969 Ok(r) => r,
1970 Err(e) => return CommandStatus::Err(format!("watch-ref-stop: bad ref: {e}")),
1971 };
1972 let key = reference.to_hex();
1973 match self.watch_refs.remove(&key) {
1974 Some(c) => {
1975 c.cancel();
1976 CommandStatus::Info(format!(
1977 "watch-ref-stop: cancelled daemon for {}",
1978 short_hex(r, 8)
1979 ))
1980 }
1981 None => CommandStatus::Err(format!(
1982 "watch-ref-stop: no daemon running for {}",
1983 short_hex(r, 8)
1984 )),
1985 }
1986 }
1987 _ => CommandStatus::Err("usage: :watch-ref-stop [ref] (omit ref to stop all)".into()),
1988 }
1989 }
1990
1991 fn run_pubsub_pss(&mut self, line: &str) -> CommandStatus {
1999 let parts: Vec<&str> = line.split_whitespace().collect();
2000 let topic_str = match parts.as_slice() {
2001 [_, t, ..] => *t,
2002 _ => return CommandStatus::Err("usage: :pubsub-pss <topic>".into()),
2003 };
2004 let parsed = match crate::feed_probe::parse_args(
2006 "0x0000000000000000000000000000000000000000",
2007 topic_str,
2008 ) {
2009 Ok(p) => p,
2010 Err(e) => return CommandStatus::Err(format!("pubsub-pss: {e}")),
2011 };
2012 let topic = parsed.topic;
2013 let sub_id = crate::pubsub::pss_sub_id(&topic);
2014 if self.pubsub_subs.contains_key(&sub_id) {
2015 return CommandStatus::Err(format!(
2016 "pubsub-pss: already subscribed to {sub_id} (use :pubsub-stop {sub_id} first)"
2017 ));
2018 }
2019 let cancel = self.root_cancel.child_token();
2020 self.pubsub_subs.insert(sub_id.clone(), cancel.clone());
2021 self.jump_to_pubsub_screen();
2022 let api = self.api.clone();
2023 let tx = self.pubsub_msg_tx.clone();
2024 let status_tx = self.cmd_status_tx.clone();
2025 let sub_id_for_task = sub_id.clone();
2026 let history = self.pubsub_history.clone();
2027 tokio::spawn(async move {
2028 if let Err(e) = crate::pubsub::spawn_pss_watcher(api, topic, cancel, tx, history).await
2029 {
2030 let _ = status_tx.send(CommandStatus::Err(format!(
2031 "pubsub-pss {sub_id_for_task}: {e}"
2032 )));
2033 }
2034 });
2035 CommandStatus::Info(format!("pubsub-pss subscribed: {sub_id}"))
2036 }
2037
2038 fn run_pubsub_gsoc(&mut self, line: &str) -> CommandStatus {
2043 let parts: Vec<&str> = line.split_whitespace().collect();
2044 let (owner_str, id_str) = match parts.as_slice() {
2045 [_, o, i, ..] => (*o, *i),
2046 _ => return CommandStatus::Err("usage: :pubsub-gsoc <owner> <identifier>".into()),
2047 };
2048 let owner = match bee::swarm::EthAddress::from_hex(owner_str.trim()) {
2049 Ok(o) => o,
2050 Err(e) => return CommandStatus::Err(format!("pubsub-gsoc: bad owner: {e}")),
2051 };
2052 let identifier = match bee::swarm::Identifier::from_hex(id_str.trim()) {
2053 Ok(i) => i,
2054 Err(e) => return CommandStatus::Err(format!("pubsub-gsoc: bad identifier: {e}")),
2055 };
2056 let sub_id = crate::pubsub::gsoc_sub_id(&owner, &identifier);
2057 if self.pubsub_subs.contains_key(&sub_id) {
2058 return CommandStatus::Err(format!(
2059 "pubsub-gsoc: already subscribed to {sub_id} (use :pubsub-stop first)"
2060 ));
2061 }
2062 let cancel = self.root_cancel.child_token();
2063 self.pubsub_subs.insert(sub_id.clone(), cancel.clone());
2064 self.jump_to_pubsub_screen();
2065 let api = self.api.clone();
2066 let tx = self.pubsub_msg_tx.clone();
2067 let status_tx = self.cmd_status_tx.clone();
2068 let sub_id_for_task = sub_id.clone();
2069 let history = self.pubsub_history.clone();
2070 tokio::spawn(async move {
2071 if let Err(e) =
2072 crate::pubsub::spawn_gsoc_watcher(api, owner, identifier, cancel, tx, history).await
2073 {
2074 let _ = status_tx.send(CommandStatus::Err(format!(
2075 "pubsub-gsoc {sub_id_for_task}: {e}"
2076 )));
2077 }
2078 });
2079 CommandStatus::Info(format!("pubsub-gsoc subscribed: {sub_id}"))
2080 }
2081
2082 fn run_pubsub_stop(&mut self, line: &str) -> CommandStatus {
2086 let parts: Vec<&str> = line.split_whitespace().collect();
2087 match parts.as_slice() {
2088 [_] => {
2089 let n = self.pubsub_subs.len();
2090 for (_, c) in self.pubsub_subs.drain() {
2091 c.cancel();
2092 }
2093 CommandStatus::Info(format!("pubsub-stop: cancelled {n} subscription(s)"))
2094 }
2095 [_, id, ..] => match self.pubsub_subs.remove(*id) {
2096 Some(c) => {
2097 c.cancel();
2098 CommandStatus::Info(format!("pubsub-stop: cancelled {id}"))
2099 }
2100 None => CommandStatus::Err(format!("pubsub-stop: no active subscription {id}")),
2101 },
2102 _ => CommandStatus::Err("usage: :pubsub-stop [sub-id]".into()),
2103 }
2104 }
2105
2106 fn jump_to_pubsub_screen(&mut self) {
2109 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Pubsub") {
2110 self.current_screen = idx;
2111 }
2112 }
2113
2114 fn run_pubsub_filter(&mut self, line: &str) -> CommandStatus {
2119 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
2120 let needle = match parts.as_slice() {
2121 [_, rest] => rest.trim().to_string(),
2122 _ => return CommandStatus::Err("usage: :pubsub-filter <substring>".into()),
2123 };
2124 if needle.is_empty() {
2125 return CommandStatus::Err("usage: :pubsub-filter <substring>".into());
2126 }
2127 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Pubsub") {
2128 if let Some(ps) = self
2129 .screens
2130 .get_mut(idx)
2131 .and_then(|s| s.as_any_mut())
2132 .and_then(|a| a.downcast_mut::<Pubsub>())
2133 {
2134 ps.set_filter(Some(needle.clone()));
2135 }
2136 self.current_screen = idx;
2137 }
2138 CommandStatus::Info(format!("pubsub-filter: showing rows containing {needle:?}"))
2139 }
2140
2141 fn run_pubsub_filter_clear(&mut self) -> CommandStatus {
2143 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Pubsub") {
2144 if let Some(ps) = self
2145 .screens
2146 .get_mut(idx)
2147 .and_then(|s| s.as_any_mut())
2148 .and_then(|a| a.downcast_mut::<Pubsub>())
2149 {
2150 ps.set_filter(None);
2151 }
2152 }
2153 CommandStatus::Info("pubsub-filter-clear: filter removed".into())
2154 }
2155
2156 fn run_pss_target(&self, line: &str) -> CommandStatus {
2161 let parts: Vec<&str> = line.split_whitespace().collect();
2162 let overlay = match parts.as_slice() {
2163 [_, o, ..] => *o,
2164 _ => {
2165 return CommandStatus::Err(
2166 "usage: :pss-target <overlay-hex> (returns first 4 hex chars)".into(),
2167 );
2168 }
2169 };
2170 match utility_verbs::pss_target_for(overlay) {
2171 Ok(prefix) => CommandStatus::Info(format!("pss target prefix: {prefix}")),
2172 Err(e) => CommandStatus::Err(format!("pss-target failed: {e}")),
2173 }
2174 }
2175
2176 fn run_price(&self) -> CommandStatus {
2182 let tx = self.cmd_status_tx.clone();
2183 tokio::spawn(async move {
2184 let status = match economics_oracle::fetch_xbzz_price().await {
2185 Ok(p) => CommandStatus::Info(p.summary()),
2186 Err(e) => CommandStatus::Err(format!("price: {e}")),
2187 };
2188 let _ = tx.send(status);
2189 });
2190 CommandStatus::Info("price: querying tokenservice.ethswarm.org…".into())
2191 }
2192
2193 fn run_basefee(&self) -> CommandStatus {
2197 let url = match self.config.economics.gnosis_rpc_url.clone() {
2198 Some(u) => u,
2199 None => {
2200 return CommandStatus::Err(
2201 "basefee: set [economics].gnosis_rpc_url in config.toml (typically the same URL as Bee's --blockchain-rpc-endpoint)"
2202 .into(),
2203 );
2204 }
2205 };
2206 let tx = self.cmd_status_tx.clone();
2207 tokio::spawn(async move {
2208 let status = match economics_oracle::fetch_gnosis_gas(&url).await {
2209 Ok(g) => CommandStatus::Info(g.summary()),
2210 Err(e) => CommandStatus::Err(format!("basefee: {e}")),
2211 };
2212 let _ = tx.send(status);
2213 });
2214 CommandStatus::Info("basefee: querying gnosis RPC…".into())
2215 }
2216
2217 fn run_config_doctor(&self) -> CommandStatus {
2223 let path = match self.config.bee.as_ref().map(|b| b.config.clone()) {
2224 Some(p) => p,
2225 None => {
2226 return CommandStatus::Err(
2227 "config-doctor: no [bee].config in config.toml (or pass --bee-config) — point bee-tui at the bee.yaml you want audited"
2228 .into(),
2229 );
2230 }
2231 };
2232 let report = match config_doctor::audit(&path) {
2233 Ok(r) => r,
2234 Err(e) => return CommandStatus::Err(format!("config-doctor: {e}")),
2235 };
2236 let secs = SystemTime::now()
2237 .duration_since(UNIX_EPOCH)
2238 .map(|d| d.as_secs())
2239 .unwrap_or(0);
2240 let out_path = std::env::temp_dir().join(format!("bee-tui-config-doctor-{secs}.txt"));
2241 if let Err(e) = std::fs::write(&out_path, report.render()) {
2242 return CommandStatus::Err(format!("config-doctor write {}: {e}", out_path.display()));
2243 }
2244 CommandStatus::Info(format!("{} → {}", report.summary(), out_path.display()))
2245 }
2246
2247 fn run_check_version(&self) -> CommandStatus {
2255 let api = self.api.clone();
2256 let tx = self.cmd_status_tx.clone();
2257 tokio::spawn(async move {
2258 let running = api.bee().debug().health().await.ok().map(|h| h.version);
2259 let status = match version_check::check_latest(running).await {
2260 Ok(v) => CommandStatus::Info(v.summary()),
2261 Err(e) => CommandStatus::Err(format!("check-version failed: {e}")),
2262 };
2263 let _ = tx.send(status);
2264 });
2265 CommandStatus::Info("check-version: querying github.com/ethersphere/bee…".into())
2266 }
2267
2268 fn run_plan_batch(&self, line: &str) -> CommandStatus {
2274 let parts: Vec<&str> = line.split_whitespace().collect();
2275 let prefix = match parts.as_slice() {
2276 [_, prefix, ..] => *prefix,
2277 _ => {
2278 return CommandStatus::Err(
2279 "usage: :plan-batch <batch-prefix> [usage-thr] [ttl-thr] [extra-depth]".into(),
2280 );
2281 }
2282 };
2283 let usage_thr = match parts.get(2) {
2284 Some(s) => match s.parse::<f64>() {
2285 Ok(v) => v,
2286 Err(_) => {
2287 return CommandStatus::Err(format!(
2288 "invalid usage-thr {s:?} (expected float in [0,1], default 0.85)"
2289 ));
2290 }
2291 },
2292 None => stamp_preview::DEFAULT_USAGE_THRESHOLD,
2293 };
2294 let ttl_thr = match parts.get(3) {
2295 Some(s) => match stamp_preview::parse_duration_seconds(s) {
2296 Ok(v) => v,
2297 Err(e) => return CommandStatus::Err(format!("ttl-thr: {e}")),
2298 },
2299 None => stamp_preview::DEFAULT_TTL_THRESHOLD_SECONDS,
2300 };
2301 let extra_depth = match parts.get(4) {
2302 Some(s) => match s.parse::<u8>() {
2303 Ok(v) => v,
2304 Err(_) => {
2305 return CommandStatus::Err(format!(
2306 "invalid extra-depth {s:?} (expected u8, default 2)"
2307 ));
2308 }
2309 },
2310 None => stamp_preview::DEFAULT_EXTRA_DEPTH,
2311 };
2312 let chain = match self.health_rx.borrow().chain_state.clone() {
2313 Some(c) => c,
2314 None => return CommandStatus::Err("chain state not loaded yet".into()),
2315 };
2316 let stamps = self.watch.stamps().borrow().clone();
2317 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
2318 Ok(b) => b.clone(),
2319 Err(e) => return CommandStatus::Err(e),
2320 };
2321 match stamp_preview::plan_batch(&batch, &chain, usage_thr, ttl_thr, extra_depth) {
2322 Ok(p) => CommandStatus::Info(p.summary()),
2323 Err(e) => CommandStatus::Err(e),
2324 }
2325 }
2326
2327 fn run_buy_suggest(&self, line: &str) -> CommandStatus {
2333 let parts: Vec<&str> = line.split_whitespace().collect();
2334 let (size_str, duration_str) = match parts.as_slice() {
2335 [_, size, duration, ..] => (*size, *duration),
2336 _ => {
2337 return CommandStatus::Err(
2338 "usage: :buy-suggest <size> <duration> (e.g. 5GiB 30d, 100MiB 12h)".into(),
2339 );
2340 }
2341 };
2342 let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
2343 Ok(b) => b,
2344 Err(e) => return CommandStatus::Err(e),
2345 };
2346 let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
2347 Ok(s) => s,
2348 Err(e) => return CommandStatus::Err(e),
2349 };
2350 let chain = match self.health_rx.borrow().chain_state.clone() {
2351 Some(c) => c,
2352 None => return CommandStatus::Err("chain state not loaded yet".into()),
2353 };
2354 match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
2355 Ok(s) => CommandStatus::Info(s.summary()),
2356 Err(e) => CommandStatus::Err(e),
2357 }
2358 }
2359
2360 fn run_buy_preview(&self, line: &str) -> CommandStatus {
2363 let parts: Vec<&str> = line.split_whitespace().collect();
2364 let (depth_str, amount_str) = match parts.as_slice() {
2365 [_, depth, amount, ..] => (*depth, *amount),
2366 _ => {
2367 return CommandStatus::Err(
2368 "usage: :buy-preview <depth> <amount-plur-per-chunk>".into(),
2369 );
2370 }
2371 };
2372 let depth: u8 = match depth_str.parse() {
2373 Ok(d) => d,
2374 Err(_) => {
2375 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
2376 }
2377 };
2378 let amount = match stamp_preview::parse_plur_amount(amount_str) {
2379 Ok(a) => a,
2380 Err(e) => return CommandStatus::Err(e),
2381 };
2382 let chain = match self.health_rx.borrow().chain_state.clone() {
2383 Some(c) => c,
2384 None => return CommandStatus::Err("chain state not loaded yet".into()),
2385 };
2386 match stamp_preview::buy_preview(depth, amount, &chain) {
2387 Ok(p) => CommandStatus::Info(p.summary()),
2388 Err(e) => CommandStatus::Err(e),
2389 }
2390 }
2391
2392 fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
2399 let node = self
2400 .config
2401 .nodes
2402 .iter()
2403 .find(|n| n.name == target)
2404 .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
2405 .clone();
2406 let new_api = Arc::new(ApiClient::from_node(&node)?);
2407 self.watch.shutdown();
2411 let refresh = RefreshProfile::from_config(&self.config.ui.refresh);
2412 let new_watch = BeeWatch::start_with_profile(new_api.clone(), &self.root_cancel, refresh);
2413 let new_health_rx = new_watch.health();
2414 let new_market_rx = if self.config.economics.enable_market_tile {
2419 Some(economics_oracle::spawn_poller(
2420 self.config.economics.gnosis_rpc_url.clone(),
2421 self.root_cancel.child_token(),
2422 ))
2423 } else {
2424 None
2425 };
2426 let new_screens = build_screens(&new_api, &new_watch, new_market_rx);
2427 self.api = new_api;
2428 self.watch = new_watch;
2429 self.health_rx = new_health_rx;
2430 self.screens = new_screens;
2431 Ok(())
2434 }
2435
2436 fn start_pins_check(&self) -> std::io::Result<PathBuf> {
2453 let secs = SystemTime::now()
2454 .duration_since(UNIX_EPOCH)
2455 .map(|d| d.as_secs())
2456 .unwrap_or(0);
2457 let path = std::env::temp_dir().join(format!(
2458 "bee-tui-pins-check-{}-{secs}.txt",
2459 sanitize_for_filename(&self.api.name),
2460 ));
2461 std::fs::write(
2464 &path,
2465 format!(
2466 "# bee-tui :pins-check\n# profile {}\n# endpoint {}\n# started {}\n",
2467 self.api.name,
2468 self.api.url,
2469 format_utc_now(),
2470 ),
2471 )?;
2472
2473 let api = self.api.clone();
2474 let dest = path.clone();
2475 tokio::spawn(async move {
2476 let bee = api.bee();
2477 match bee.api().check_pins(None).await {
2478 Ok(entries) => {
2479 let mut body = String::new();
2480 for e in &entries {
2481 body.push_str(&format!(
2482 "{} total={} missing={} invalid={} {}\n",
2483 e.reference.to_hex(),
2484 e.total,
2485 e.missing,
2486 e.invalid,
2487 if e.is_healthy() {
2488 "healthy"
2489 } else {
2490 "UNHEALTHY"
2491 },
2492 ));
2493 }
2494 body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
2495 if let Err(e) = append(&dest, &body) {
2496 let _ = append(&dest, &format!("# write error: {e}\n"));
2497 }
2498 }
2499 Err(e) => {
2500 let _ = append(&dest, &format!("# error: {e}\n"));
2501 }
2502 }
2503 });
2504 Ok(path)
2505 }
2506
2507 fn start_set_logger(&self, expression: String, level: String) {
2518 let secs = SystemTime::now()
2519 .duration_since(UNIX_EPOCH)
2520 .map(|d| d.as_secs())
2521 .unwrap_or(0);
2522 let dest = std::env::temp_dir().join(format!(
2523 "bee-tui-set-logger-{}-{secs}.txt",
2524 sanitize_for_filename(&self.api.name),
2525 ));
2526 let _ = std::fs::write(
2527 &dest,
2528 format!(
2529 "# bee-tui :set-logger\n# profile {}\n# endpoint {}\n# expr {expression}\n# level {level}\n# started {}\n",
2530 self.api.name,
2531 self.api.url,
2532 format_utc_now(),
2533 ),
2534 );
2535
2536 let api = self.api.clone();
2537 tokio::spawn(async move {
2538 let bee = api.bee();
2539 match bee.debug().set_logger(&expression, &level).await {
2540 Ok(()) => {
2541 let _ = append(
2542 &dest,
2543 &format!("# done. {expression} → {level} accepted by Bee.\n"),
2544 );
2545 }
2546 Err(e) => {
2547 let _ = append(&dest, &format!("# error: {e}\n"));
2548 }
2549 }
2550 });
2551 }
2552
2553 fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
2558 let secs = SystemTime::now()
2559 .duration_since(UNIX_EPOCH)
2560 .map(|d| d.as_secs())
2561 .unwrap_or(0);
2562 let path = std::env::temp_dir().join(format!(
2563 "bee-tui-loggers-{}-{secs}.txt",
2564 sanitize_for_filename(&self.api.name),
2565 ));
2566 std::fs::write(
2567 &path,
2568 format!(
2569 "# bee-tui :loggers\n# profile {}\n# endpoint {}\n# started {}\n",
2570 self.api.name,
2571 self.api.url,
2572 format_utc_now(),
2573 ),
2574 )?;
2575
2576 let api = self.api.clone();
2577 let dest = path.clone();
2578 tokio::spawn(async move {
2579 let bee = api.bee();
2580 match bee.debug().loggers().await {
2581 Ok(listing) => {
2582 let mut rows = listing.loggers.clone();
2583 rows.sort_by(|a, b| {
2587 verbosity_rank(&b.verbosity)
2588 .cmp(&verbosity_rank(&a.verbosity))
2589 .then_with(|| a.logger.cmp(&b.logger))
2590 });
2591 let mut body = String::new();
2592 body.push_str(&format!("# {} loggers registered\n", rows.len()));
2593 body.push_str("# VERBOSITY LOGGER\n");
2594 for r in &rows {
2595 body.push_str(&format!(" {:<9} {}\n", r.verbosity, r.logger,));
2596 }
2597 body.push_str("# done.\n");
2598 if let Err(e) = append(&dest, &body) {
2599 let _ = append(&dest, &format!("# write error: {e}\n"));
2600 }
2601 }
2602 Err(e) => {
2603 let _ = append(&dest, &format!("# error: {e}\n"));
2604 }
2605 }
2606 });
2607 Ok(path)
2608 }
2609
2610 fn start_diagnose_with_pprof(&self, seconds: u32) -> CommandStatus {
2622 let secs_unix = SystemTime::now()
2623 .duration_since(UNIX_EPOCH)
2624 .map(|d| d.as_secs())
2625 .unwrap_or(0);
2626 let dir = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs_unix}"));
2627 if let Err(e) = std::fs::create_dir_all(&dir) {
2628 return CommandStatus::Err(format!("diagnose --pprof: mkdir failed: {e}"));
2629 }
2630 let bundle_text = self.render_diagnostic_bundle();
2631 if let Err(e) = std::fs::write(dir.join("bundle.txt"), &bundle_text) {
2632 return CommandStatus::Err(format!("diagnose --pprof: write bundle.txt: {e}"));
2633 }
2634 let auth_token = self
2639 .config
2640 .nodes
2641 .iter()
2642 .find(|n| n.name == self.api.name)
2643 .and_then(|n| n.resolved_token());
2644 let base_url = self.api.url.clone();
2645 let dir_for_task = dir.clone();
2646 let tx = self.cmd_status_tx.clone();
2647 tokio::spawn(async move {
2648 let r =
2649 pprof_bundle::fetch_and_write(&base_url, auth_token, seconds, dir_for_task).await;
2650 let status = match r {
2651 Ok(b) => CommandStatus::Info(b.summary()),
2652 Err(e) => CommandStatus::Err(format!("diagnose --pprof failed: {e}")),
2653 };
2654 let _ = tx.send(status);
2655 });
2656 CommandStatus::Info(format!(
2657 "diagnose --pprof={seconds}s in flight (bundle.txt already at {}; profile + trace will join when sampling completes)",
2658 dir.display()
2659 ))
2660 }
2661
2662 fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
2663 let bundle = self.render_diagnostic_bundle();
2664 let secs = SystemTime::now()
2665 .duration_since(UNIX_EPOCH)
2666 .map(|d| d.as_secs())
2667 .unwrap_or(0);
2668 let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
2669 std::fs::write(&path, bundle)?;
2670 Ok(path)
2671 }
2672
2673 fn render_diagnostic_bundle(&self) -> String {
2674 let now = format_utc_now();
2675 let health = self.health_rx.borrow().clone();
2676 let topology = self.watch.topology().borrow().clone();
2677 let stamps = self.watch.stamps().borrow().clone();
2678 let gates = Health::gates_for_with_stamps(&health, Some(&topology), Some(&stamps));
2679 let recent: Vec<_> = log_capture::handle()
2680 .map(|c| {
2681 let mut snap = c.snapshot();
2682 let len = snap.len();
2683 if len > 50 {
2684 snap.drain(0..len - 50);
2685 }
2686 snap
2687 })
2688 .unwrap_or_default();
2689
2690 let mut out = String::new();
2691 out.push_str("# bee-tui diagnostic bundle\n");
2692 out.push_str(&format!("# generated UTC {now}\n\n"));
2693 out.push_str("## profile\n");
2694 out.push_str(&format!(" name {}\n", self.api.name));
2695 out.push_str(&format!(" endpoint {}\n\n", self.api.url));
2696 out.push_str("## health gates\n");
2697 for g in &gates {
2698 out.push_str(&format_gate_line(g));
2699 }
2700 out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
2701 for e in &recent {
2702 let status = e
2703 .status
2704 .map(|s| s.to_string())
2705 .unwrap_or_else(|| "—".into());
2706 let elapsed = e
2707 .elapsed_ms
2708 .map(|ms| format!("{ms}ms"))
2709 .unwrap_or_else(|| "—".into());
2710 out.push_str(&format!(
2711 " {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
2712 ts = e.ts,
2713 method = e.method,
2714 path = path_only(&e.url),
2715 status = status,
2716 elapsed = elapsed,
2717 ));
2718 }
2719 out.push_str(&format!(
2720 "\n## generated by bee-tui {}\n",
2721 env!("CARGO_PKG_VERSION"),
2722 ));
2723 out
2724 }
2725
2726 fn tick_alerts(&mut self) {
2733 let url = match self.config.alerts.webhook_url.as_deref() {
2734 Some(u) if !u.is_empty() => u.to_string(),
2735 _ => return,
2736 };
2737 let health = self.health_rx.borrow().clone();
2738 let topology = self.watch.topology().borrow().clone();
2739 let stamps = self.watch.stamps().borrow().clone();
2740 let gates = Health::gates_for_with_stamps(&health, Some(&topology), Some(&stamps));
2741 let alerts = self.alert_state.diff_and_record(&gates);
2742 for alert in alerts {
2743 let url = url.clone();
2744 tokio::spawn(async move {
2745 if let Err(e) = crate::alerts::fire(&url, &alert).await {
2746 tracing::warn!(target: "bee_tui::alerts", "webhook fire failed: {e}");
2747 }
2748 });
2749 }
2750 }
2751
2752 fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
2753 while let Ok(action) = self.action_rx.try_recv() {
2754 if action != Action::Tick && action != Action::Render {
2755 debug!("{action:?}");
2756 }
2757 match action {
2758 Action::Tick => {
2759 self.last_tick_key_events.drain(..);
2760 theme::advance_spinner();
2764 if let Some(sup) = self.supervisor.as_mut() {
2768 self.bee_status = sup.status();
2769 }
2770 if let Some(rx) = self.bee_log_rx.as_mut() {
2775 while let Ok((tab, line)) = rx.try_recv() {
2776 self.log_pane.push_bee(tab, line);
2777 }
2778 }
2779 while let Ok(status) = self.cmd_status_rx.try_recv() {
2784 self.command_status = Some(status);
2785 }
2786 while let Ok(result) = self.durability_rx.try_recv() {
2791 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Watchlist") {
2792 if let Some(wl) = self
2793 .screens
2794 .get_mut(idx)
2795 .and_then(|s| s.as_any_mut())
2796 .and_then(|a| a.downcast_mut::<Watchlist>())
2797 {
2798 wl.record(result);
2799 }
2800 }
2801 }
2802 while let Ok(msg) = self.feed_timeline_rx.try_recv() {
2807 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "FeedTimeline") {
2808 if let Some(ft) = self
2809 .screens
2810 .get_mut(idx)
2811 .and_then(|s| s.as_any_mut())
2812 .and_then(|a| a.downcast_mut::<FeedTimeline>())
2813 {
2814 match msg {
2815 FeedTimelineMessage::Loaded(t) => ft.set_timeline(t),
2816 FeedTimelineMessage::Failed(e) => ft.set_error(e),
2817 }
2818 }
2819 }
2820 }
2821 let mut buffered: Vec<crate::pubsub::PubsubMessage> = Vec::new();
2825 while let Ok(msg) = self.pubsub_msg_rx.try_recv() {
2826 buffered.push(msg);
2827 }
2828 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Pubsub") {
2829 if let Some(ps) = self
2830 .screens
2831 .get_mut(idx)
2832 .and_then(|s| s.as_any_mut())
2833 .and_then(|a| a.downcast_mut::<Pubsub>())
2834 {
2835 for m in buffered {
2836 ps.record(m);
2837 }
2838 ps.set_active_count(self.pubsub_subs.len());
2839 }
2840 }
2841 self.tick_alerts();
2845 }
2846 Action::Quit => self.should_quit = true,
2847 Action::Suspend => self.should_suspend = true,
2848 Action::Resume => self.should_suspend = false,
2849 Action::ClearScreen => tui.terminal.clear()?,
2850 Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
2851 Action::Render => self.render(tui)?,
2852 _ => {}
2853 }
2854 let tx = self.action_tx.clone();
2855 for component in self.iter_components_mut() {
2856 if let Some(action) = component.update(action.clone())? {
2857 tx.send(action)?
2858 };
2859 }
2860 }
2861 Ok(())
2862 }
2863
2864 fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
2865 tui.resize(Rect::new(0, 0, w, h))?;
2866 self.render(tui)?;
2867 Ok(())
2868 }
2869
2870 fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
2871 let active = self.current_screen;
2872 let tx = self.action_tx.clone();
2873 let screens = &mut self.screens;
2874 let log_pane = &mut self.log_pane;
2875 let log_pane_height = log_pane.height();
2876 let command_buffer = self.command_buffer.clone();
2877 let command_suggestion_index = self.command_suggestion_index;
2878 let command_status = self.command_status.clone();
2879 let help_visible = self.help_visible;
2880 let profile = self.api.name.clone();
2881 let endpoint = self.api.url.clone();
2882 let last_ping = self.health_rx.borrow().last_ping;
2883 let now_utc = format_utc_now();
2884 let bee_status_label = if self.supervisor.is_some() && !self.bee_status.is_running() {
2885 Some(self.bee_status.label())
2889 } else {
2890 None
2891 };
2892 tui.draw(|frame| {
2893 use ratatui::layout::{Constraint, Layout};
2894 use ratatui::style::{Color, Modifier, Style};
2895 use ratatui::text::{Line, Span};
2896 use ratatui::widgets::Paragraph;
2897
2898 let chunks = Layout::vertical([
2899 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), Constraint::Length(log_pane_height), ])
2904 .split(frame.area());
2905
2906 let top_chunks =
2907 Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(chunks[0]);
2908
2909 let ping_str = match last_ping {
2911 Some(d) => format!("{}ms", d.as_millis()),
2912 None => "—".into(),
2913 };
2914 let t = theme::active();
2915 let mut metadata_spans = vec![
2916 Span::styled(
2917 " bee-tui ",
2918 Style::default()
2919 .fg(Color::Black)
2920 .bg(t.info)
2921 .add_modifier(Modifier::BOLD),
2922 ),
2923 Span::raw(" "),
2924 Span::styled(
2925 profile,
2926 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
2927 ),
2928 Span::styled(format!(" @ {endpoint}"), Style::default().fg(t.dim)),
2929 Span::raw(" "),
2930 Span::styled("ping ", Style::default().fg(t.dim)),
2931 Span::styled(ping_str, Style::default().fg(t.info)),
2932 Span::raw(" "),
2933 Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
2934 ];
2935 if let Some(label) = bee_status_label.as_ref() {
2939 metadata_spans.push(Span::raw(" "));
2940 metadata_spans.push(Span::styled(
2941 format!(" {label} "),
2942 Style::default()
2943 .fg(Color::Black)
2944 .bg(t.fail)
2945 .add_modifier(Modifier::BOLD),
2946 ));
2947 }
2948 let metadata_line = Line::from(metadata_spans);
2949 frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
2950
2951 let theme = *theme::active();
2953 let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
2954 for (i, name) in SCREEN_NAMES.iter().enumerate() {
2955 let style = if i == active {
2956 Style::default()
2957 .fg(theme.tab_active_fg)
2958 .bg(theme.tab_active_bg)
2959 .add_modifier(Modifier::BOLD)
2960 } else {
2961 Style::default().fg(theme.dim)
2962 };
2963 tabs.push(Span::styled(format!(" {name} "), style));
2964 tabs.push(Span::raw(" "));
2965 }
2966 tabs.push(Span::styled(
2967 ":cmd · Tab to cycle · ? help",
2968 Style::default().fg(theme.dim),
2969 ));
2970 frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
2971
2972 if let Some(screen) = screens.get_mut(active) {
2974 if let Err(err) = screen.draw(frame, chunks[1]) {
2975 let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
2976 }
2977 }
2978 let prompt = if let Some(buf) = &command_buffer {
2980 Line::from(vec![
2981 Span::styled(
2982 ":",
2983 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
2984 ),
2985 Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
2986 Span::styled("█", Style::default().fg(t.accent)),
2987 ])
2988 } else {
2989 match &command_status {
2990 Some(CommandStatus::Info(msg)) => {
2991 Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
2992 }
2993 Some(CommandStatus::Err(msg)) => {
2994 Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
2995 }
2996 None => Line::from(""),
2997 }
2998 };
2999 frame.render_widget(Paragraph::new(prompt), chunks[2]);
3000
3001 if let Some(buf) = &command_buffer {
3007 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
3008 if !matches.is_empty() {
3009 draw_command_suggestions(
3010 frame,
3011 chunks[2],
3012 &matches,
3013 command_suggestion_index,
3014 &theme,
3015 );
3016 }
3017 }
3018
3019 if let Err(err) = log_pane.draw(frame, chunks[3]) {
3021 let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
3022 }
3023
3024 if help_visible {
3029 draw_help_overlay(frame, frame.area(), active, &theme);
3030 }
3031 })?;
3032 Ok(())
3033 }
3034}
3035
3036fn draw_command_suggestions(
3043 frame: &mut ratatui::Frame,
3044 bar_rect: ratatui::layout::Rect,
3045 matches: &[&(&str, &str)],
3046 selected: usize,
3047 theme: &theme::Theme,
3048) {
3049 use ratatui::layout::Rect;
3050 use ratatui::style::{Modifier, Style};
3051 use ratatui::text::{Line, Span};
3052 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
3053
3054 const MAX_VISIBLE: usize = 10;
3055 let visible_rows = matches.len().min(MAX_VISIBLE);
3056 if visible_rows == 0 {
3057 return;
3058 }
3059 let height = (visible_rows as u16) + 2; let widest = matches
3064 .iter()
3065 .map(|(name, desc)| name.len() + desc.len() + 6)
3066 .max()
3067 .unwrap_or(40)
3068 .min(bar_rect.width as usize);
3069 let width = (widest as u16 + 2).min(bar_rect.width);
3070 let bottom = bar_rect.y;
3073 let y = bottom.saturating_sub(height);
3074 let popup = Rect {
3075 x: bar_rect.x,
3076 y,
3077 width,
3078 height: bottom - y,
3079 };
3080
3081 let scroll_start = if selected >= visible_rows {
3083 selected + 1 - visible_rows
3084 } else {
3085 0
3086 };
3087 let visible_slice = &matches[scroll_start..(scroll_start + visible_rows).min(matches.len())];
3088
3089 let mut lines: Vec<Line> = Vec::with_capacity(visible_slice.len());
3090 for (i, (name, desc)) in visible_slice.iter().enumerate() {
3091 let absolute_idx = scroll_start + i;
3092 let is_selected = absolute_idx == selected;
3093 let row_style = if is_selected {
3094 Style::default()
3095 .fg(theme.tab_active_fg)
3096 .bg(theme.tab_active_bg)
3097 .add_modifier(Modifier::BOLD)
3098 } else {
3099 Style::default()
3100 };
3101 let cursor = if is_selected { "▸ " } else { " " };
3102 lines.push(Line::from(vec![
3103 Span::styled(format!("{cursor}:{name:<16} "), row_style),
3104 Span::styled(
3105 desc.to_string(),
3106 if is_selected {
3107 row_style
3108 } else {
3109 Style::default().fg(theme.dim)
3110 },
3111 ),
3112 ]));
3113 }
3114
3115 let title = if matches.len() > MAX_VISIBLE {
3117 format!(" :commands ({}/{}) ", selected + 1, matches.len())
3118 } else {
3119 " :commands ".to_string()
3120 };
3121
3122 frame.render_widget(Clear, popup);
3123 frame.render_widget(
3124 Paragraph::new(lines).block(
3125 Block::default()
3126 .borders(Borders::ALL)
3127 .border_style(Style::default().fg(theme.accent))
3128 .title(title),
3129 ),
3130 popup,
3131 );
3132}
3133
3134fn draw_help_overlay(
3139 frame: &mut ratatui::Frame,
3140 area: ratatui::layout::Rect,
3141 active_screen: usize,
3142 theme: &theme::Theme,
3143) {
3144 use ratatui::layout::Rect;
3145 use ratatui::style::{Modifier, Style};
3146 use ratatui::text::{Line, Span};
3147 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
3148
3149 let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
3150 let screen_rows = screen_keymap(active_screen);
3151 let global_rows: &[(&str, &str)] = &[
3152 ("Tab", "next screen"),
3153 ("Shift+Tab", "previous screen"),
3154 ("[ / ]", "previous / next log-pane tab"),
3155 ("+ / -", "grow / shrink log pane"),
3156 ("Shift+↑/↓", "scroll log pane (1 line); pauses auto-tail"),
3157 ("Shift+PgUp/PgDn", "scroll log pane (10 lines)"),
3158 ("Shift+←/→", "pan log pane horizontally (8 cols)"),
3159 ("Shift+End", "resume auto-tail + reset horizontal pan"),
3160 ("?", "toggle this help"),
3161 (":", "open command bar"),
3162 ("qq", "quit (double-tap; or :q)"),
3163 ("Ctrl+C / Ctrl+D", "quit immediately"),
3164 ];
3165
3166 let w = area.width.min(72);
3169 let h = area.height.min(22);
3170 let x = area.x + (area.width.saturating_sub(w)) / 2;
3171 let y = area.y + (area.height.saturating_sub(h)) / 2;
3172 let rect = Rect {
3173 x,
3174 y,
3175 width: w,
3176 height: h,
3177 };
3178
3179 let mut lines: Vec<Line> = Vec::new();
3180 lines.push(Line::from(vec![
3181 Span::styled(
3182 format!(" {screen_name} "),
3183 Style::default()
3184 .fg(theme.tab_active_fg)
3185 .bg(theme.tab_active_bg)
3186 .add_modifier(Modifier::BOLD),
3187 ),
3188 Span::raw(" screen-specific keys"),
3189 ]));
3190 lines.push(Line::from(""));
3191 if screen_rows.is_empty() {
3192 lines.push(Line::from(Span::styled(
3193 " (no extra keys for this screen — use the command bar via :)",
3194 Style::default()
3195 .fg(theme.dim)
3196 .add_modifier(Modifier::ITALIC),
3197 )));
3198 } else {
3199 for (key, desc) in screen_rows {
3200 lines.push(format_help_row(key, desc, theme));
3201 }
3202 }
3203 lines.push(Line::from(""));
3204 lines.push(Line::from(Span::styled(
3205 " global",
3206 Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
3207 )));
3208 for (key, desc) in global_rows {
3209 lines.push(format_help_row(key, desc, theme));
3210 }
3211 lines.push(Line::from(""));
3212 lines.push(Line::from(Span::styled(
3213 " Esc / ? / q to dismiss",
3214 Style::default()
3215 .fg(theme.dim)
3216 .add_modifier(Modifier::ITALIC),
3217 )));
3218
3219 frame.render_widget(Clear, rect);
3222 frame.render_widget(
3223 Paragraph::new(lines).block(
3224 Block::default()
3225 .borders(Borders::ALL)
3226 .border_style(Style::default().fg(theme.accent))
3227 .title(" help "),
3228 ),
3229 rect,
3230 );
3231}
3232
3233fn format_help_row<'a>(
3234 key: &'a str,
3235 desc: &'a str,
3236 theme: &theme::Theme,
3237) -> ratatui::text::Line<'a> {
3238 use ratatui::style::{Modifier, Style};
3239 use ratatui::text::{Line, Span};
3240 Line::from(vec![
3241 Span::raw(" "),
3242 Span::styled(
3243 format!("{key:<16}"),
3244 Style::default()
3245 .fg(theme.accent)
3246 .add_modifier(Modifier::BOLD),
3247 ),
3248 Span::raw(" "),
3249 Span::raw(desc),
3250 ])
3251}
3252
3253fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
3257 match active_screen {
3258 1 => &[
3260 ("↑↓ / j k", "move row selection"),
3261 ("Enter", "drill batch — bucket histogram + worst-N"),
3262 ("Esc", "close drill"),
3263 ],
3264 3 => &[("r", "run on-demand rchash benchmark")],
3266 4 => &[
3267 ("↑↓ / j k", "move peer selection"),
3268 (
3269 "Enter",
3270 "drill peer — balance / cheques / settlement / ping",
3271 ),
3272 ("Esc", "close drill"),
3273 ],
3274 8 => &[
3278 ("↑↓ / j k", "scroll one row"),
3279 ("PgUp / PgDn", "scroll ten rows"),
3280 ("Home", "back to top"),
3281 ],
3282 9 => &[
3284 ("↑↓ / j k", "move row selection"),
3285 ("Enter", "integrity-check the highlighted pin"),
3286 ("c", "integrity-check every unchecked pin"),
3287 ("s", "cycle sort: ref order / bad first / by size"),
3288 ],
3289 10 => &[
3291 ("↑↓ / j k", "move row selection"),
3292 ("Enter", "expand / collapse fork (loads child chunk)"),
3293 (":manifest <ref>", "open a manifest at a reference"),
3294 (":inspect <ref>", "what is this? auto-detects manifest"),
3295 ],
3296 11 => &[
3298 ("↑↓ / j k", "move row selection"),
3299 (":durability-check <ref>", "walk chunk graph + record"),
3300 ],
3301 12 => &[
3303 ("↑↓ / j k", "move row selection"),
3304 ("PgUp / PgDn", "jump 10 rows"),
3305 (
3306 ":feed-timeline <owner> <topic> [N]",
3307 "load history (default 50)",
3308 ),
3309 ],
3310 13 => &[
3312 ("↑↓ / j k", "move row selection"),
3313 ("PgUp / PgDn", "jump 10 rows"),
3314 ("c", "clear timeline"),
3315 (":pubsub-pss <topic>", "subscribe to a PSS topic"),
3316 (":pubsub-gsoc <owner> <id>", "subscribe to a GSOC SOC"),
3317 (":pubsub-stop [sub-id]", "stop one (or all) subscriptions"),
3318 (
3319 ":pubsub-filter <substr>",
3320 "show only rows containing substring",
3321 ),
3322 (":pubsub-filter-clear", "remove the active filter"),
3323 ],
3324 _ => &[],
3325 }
3326}
3327
3328fn build_screens(
3337 api: &Arc<ApiClient>,
3338 watch: &BeeWatch,
3339 market_rx: Option<watch::Receiver<crate::economics_oracle::EconomicsSnapshot>>,
3340) -> Vec<Box<dyn Component>> {
3341 let health = Health::new(api.clone(), watch.health(), watch.topology());
3342 let stamps = Stamps::new(api.clone(), watch.stamps());
3343 let swap = match market_rx {
3344 Some(rx) => Swap::new(watch.swap()).with_market_feed(rx),
3345 None => Swap::new(watch.swap()),
3346 };
3347 let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
3348 let peers = Peers::new(api.clone(), watch.topology());
3349 let network = Network::new(watch.network(), watch.topology());
3350 let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
3351 let api_health = ApiHealth::new(
3352 api.clone(),
3353 watch.health(),
3354 watch.transactions(),
3355 log_capture::handle(),
3356 );
3357 let tags = Tags::new(watch.tags());
3358 let pins = Pins::new(api.clone(), watch.pins());
3359 let manifest = Manifest::new(api.clone());
3360 let watchlist = Watchlist::new();
3361 let feed_timeline = FeedTimeline::new();
3362 let pubsub_screen = Pubsub::new();
3363 vec![
3364 Box::new(health),
3365 Box::new(stamps),
3366 Box::new(swap),
3367 Box::new(lottery),
3368 Box::new(peers),
3369 Box::new(network),
3370 Box::new(warmup),
3371 Box::new(api_health),
3372 Box::new(tags),
3373 Box::new(pins),
3374 Box::new(manifest),
3375 Box::new(watchlist),
3376 Box::new(feed_timeline),
3377 Box::new(pubsub_screen),
3378 ]
3379}
3380
3381fn build_synthetic_probe_chunk() -> Vec<u8> {
3389 use std::time::{SystemTime, UNIX_EPOCH};
3390 let nanos = SystemTime::now()
3391 .duration_since(UNIX_EPOCH)
3392 .map(|d| d.as_nanos())
3393 .unwrap_or(0);
3394 let mut data = Vec::with_capacity(8 + 4096);
3395 data.extend_from_slice(&4096u64.to_le_bytes());
3397 data.extend_from_slice(&nanos.to_le_bytes());
3399 data.resize(8 + 4096, 0);
3400 data
3401}
3402
3403fn short_hex(hex: &str, len: usize) -> String {
3406 if hex.len() > len {
3407 format!("{}…", &hex[..len])
3408 } else {
3409 hex.to_string()
3410 }
3411}
3412
3413fn guess_content_type(path: &std::path::Path) -> String {
3419 let ext = path
3420 .extension()
3421 .and_then(|e| e.to_str())
3422 .map(|s| s.to_ascii_lowercase());
3423 match ext.as_deref() {
3424 Some("html") | Some("htm") => "text/html",
3425 Some("txt") | Some("md") => "text/plain",
3426 Some("json") => "application/json",
3427 Some("css") => "text/css",
3428 Some("js") => "application/javascript",
3429 Some("png") => "image/png",
3430 Some("jpg") | Some("jpeg") => "image/jpeg",
3431 Some("gif") => "image/gif",
3432 Some("svg") => "image/svg+xml",
3433 Some("webp") => "image/webp",
3434 Some("pdf") => "application/pdf",
3435 Some("zip") => "application/zip",
3436 Some("tar") => "application/x-tar",
3437 Some("gz") | Some("tgz") => "application/gzip",
3438 Some("wasm") => "application/wasm",
3439 _ => "",
3440 }
3441 .to_string()
3442}
3443
3444fn build_metrics_render_fn(
3450 watch: BeeWatch,
3451 log_capture: Option<log_capture::LogCapture>,
3452) -> crate::metrics_server::RenderFn {
3453 use std::time::{SystemTime, UNIX_EPOCH};
3454 Arc::new(move || {
3455 let health = watch.health().borrow().clone();
3456 let stamps = watch.stamps().borrow().clone();
3457 let swap = watch.swap().borrow().clone();
3458 let lottery = watch.lottery().borrow().clone();
3459 let topology = watch.topology().borrow().clone();
3460 let network = watch.network().borrow().clone();
3461 let transactions = watch.transactions().borrow().clone();
3462 let recent = log_capture
3463 .as_ref()
3464 .map(|c| c.snapshot())
3465 .unwrap_or_default();
3466 let call_stats = crate::components::api_health::call_stats_for(&recent);
3467 let now_unix = SystemTime::now()
3468 .duration_since(UNIX_EPOCH)
3469 .map(|d| d.as_secs() as i64)
3470 .unwrap_or(0);
3471 let inputs = crate::metrics::MetricsInputs {
3472 bee_tui_version: env!("CARGO_PKG_VERSION"),
3473 health: &health,
3474 stamps: &stamps,
3475 swap: &swap,
3476 lottery: &lottery,
3477 topology: &topology,
3478 network: &network,
3479 transactions: &transactions,
3480 call_stats: &call_stats,
3481 now_unix,
3482 };
3483 crate::metrics::render(&inputs)
3484 })
3485}
3486
3487fn format_gate_line(g: &Gate) -> String {
3488 let glyphs = crate::theme::active().glyphs;
3489 let glyph = match g.status {
3490 GateStatus::Pass => glyphs.pass,
3491 GateStatus::Warn => glyphs.warn,
3492 GateStatus::Fail => glyphs.fail,
3493 GateStatus::Unknown => glyphs.bullet,
3494 };
3495 let mut s = format!(
3496 " [{glyph}] {label:<28} {value}\n",
3497 label = g.label,
3498 value = g.value
3499 );
3500 if let Some(why) = &g.why {
3501 s.push_str(&format!(" {} {why}\n", glyphs.continuation));
3502 }
3503 s
3504}
3505
3506fn path_only(url: &str) -> String {
3509 if let Some(idx) = url.find("//") {
3510 let after_scheme = &url[idx + 2..];
3511 if let Some(slash) = after_scheme.find('/') {
3512 return after_scheme[slash..].to_string();
3513 }
3514 return "/".into();
3515 }
3516 url.to_string()
3517}
3518
3519fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
3526 use std::io::Write;
3527 let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
3528 f.write_all(s.as_bytes())
3529}
3530
3531fn verbosity_rank(s: &str) -> u8 {
3537 match s {
3538 "all" | "trace" => 5,
3539 "debug" => 4,
3540 "info" | "1" => 3,
3541 "warning" | "warn" | "2" => 2,
3542 "error" | "3" => 1,
3543 _ => 0,
3544 }
3545}
3546
3547fn sanitize_for_filename(s: &str) -> String {
3551 s.chars()
3552 .map(|c| match c {
3553 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
3554 _ => '-',
3555 })
3556 .collect()
3557}
3558
3559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3563pub enum QuitResolution {
3564 Confirm,
3566 Pending,
3569}
3570
3571fn resolve_quit_press(prev: Option<Instant>, now: Instant, window: Duration) -> QuitResolution {
3576 match prev {
3577 Some(t) if now.duration_since(t) <= window => QuitResolution::Confirm,
3578 _ => QuitResolution::Pending,
3579 }
3580}
3581
3582fn format_utc_now() -> String {
3583 let secs = SystemTime::now()
3584 .duration_since(UNIX_EPOCH)
3585 .map(|d| d.as_secs())
3586 .unwrap_or(0);
3587 let secs_in_day = secs % 86_400;
3588 let h = secs_in_day / 3_600;
3589 let m = (secs_in_day % 3_600) / 60;
3590 let s = secs_in_day % 60;
3591 format!("{h:02}:{m:02}:{s:02}")
3592}
3593
3594#[cfg(test)]
3595mod tests {
3596 use super::*;
3597
3598 #[test]
3599 fn format_utc_now_returns_eight_chars() {
3600 let s = format_utc_now();
3601 assert_eq!(s.len(), 8);
3602 assert_eq!(s.chars().nth(2), Some(':'));
3603 assert_eq!(s.chars().nth(5), Some(':'));
3604 }
3605
3606 #[test]
3607 fn path_only_strips_scheme_and_host() {
3608 assert_eq!(path_only("http://10.0.1.5:1633/status"), "/status");
3609 assert_eq!(
3610 path_only("https://bee.example.com/stamps?limit=10"),
3611 "/stamps?limit=10"
3612 );
3613 }
3614
3615 #[test]
3616 fn path_only_handles_no_path() {
3617 assert_eq!(path_only("http://localhost:1633"), "/");
3618 }
3619
3620 #[test]
3621 fn path_only_passes_relative_through() {
3622 assert_eq!(path_only("/already/relative"), "/already/relative");
3623 }
3624
3625 #[test]
3626 fn parse_pprof_arg_default_60() {
3627 assert_eq!(parse_pprof_arg("diagnose --pprof"), Some(60));
3628 assert_eq!(parse_pprof_arg("diag --pprof some other"), Some(60));
3629 }
3630
3631 #[test]
3632 fn parse_pprof_arg_with_explicit_seconds() {
3633 assert_eq!(parse_pprof_arg("diagnose --pprof=120"), Some(120));
3634 assert_eq!(parse_pprof_arg("diagnose --pprof=15 trailing"), Some(15));
3635 }
3636
3637 #[test]
3638 fn parse_pprof_arg_clamps_extreme_values() {
3639 assert_eq!(parse_pprof_arg("diagnose --pprof=0"), Some(1));
3641 assert_eq!(parse_pprof_arg("diagnose --pprof=9999"), Some(600));
3642 }
3643
3644 #[test]
3645 fn parse_pprof_arg_none_when_absent() {
3646 assert_eq!(parse_pprof_arg("diagnose"), None);
3647 assert_eq!(parse_pprof_arg("diag"), None);
3648 assert_eq!(parse_pprof_arg(""), None);
3649 }
3650
3651 #[test]
3652 fn parse_pprof_arg_ignores_garbage_value() {
3653 assert_eq!(parse_pprof_arg("diagnose --pprof=lol"), None);
3656 }
3657
3658 #[test]
3659 fn guess_content_type_known_extensions() {
3660 let p = std::path::PathBuf::from;
3661 assert_eq!(guess_content_type(&p("/tmp/x.html")), "text/html");
3662 assert_eq!(guess_content_type(&p("/tmp/x.json")), "application/json");
3663 assert_eq!(guess_content_type(&p("/tmp/x.PNG")), "image/png");
3664 assert_eq!(guess_content_type(&p("/tmp/x.tar.gz")), "application/gzip");
3665 }
3666
3667 #[test]
3668 fn guess_content_type_unknown_returns_empty() {
3669 let p = std::path::PathBuf::from;
3670 assert_eq!(guess_content_type(&p("/tmp/x.unknownext")), "");
3673 assert_eq!(guess_content_type(&p("/tmp/no-extension")), "");
3674 }
3675
3676 #[test]
3677 fn sanitize_for_filename_keeps_safe_chars() {
3678 assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
3679 assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
3680 }
3681
3682 #[test]
3683 fn sanitize_for_filename_replaces_unsafe_chars() {
3684 assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
3685 assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
3686 }
3687
3688 #[test]
3689 fn resolve_quit_press_first_press_is_pending() {
3690 let now = Instant::now();
3691 assert_eq!(
3692 resolve_quit_press(None, now, Duration::from_millis(1500)),
3693 QuitResolution::Pending
3694 );
3695 }
3696
3697 #[test]
3698 fn resolve_quit_press_second_press_inside_window_confirms() {
3699 let first = Instant::now();
3700 let window = Duration::from_millis(1500);
3701 let second = first + Duration::from_millis(500);
3702 assert_eq!(
3703 resolve_quit_press(Some(first), second, window),
3704 QuitResolution::Confirm
3705 );
3706 }
3707
3708 #[test]
3709 fn resolve_quit_press_second_press_after_window_resets_to_pending() {
3710 let first = Instant::now();
3714 let window = Duration::from_millis(1500);
3715 let second = first + Duration::from_millis(2_000);
3716 assert_eq!(
3717 resolve_quit_press(Some(first), second, window),
3718 QuitResolution::Pending
3719 );
3720 }
3721
3722 #[test]
3723 fn resolve_quit_press_at_window_boundary_confirms() {
3724 let first = Instant::now();
3727 let window = Duration::from_millis(1500);
3728 let second = first + window;
3729 assert_eq!(
3730 resolve_quit_press(Some(first), second, window),
3731 QuitResolution::Confirm
3732 );
3733 }
3734
3735 #[test]
3736 fn screen_keymap_covers_drill_screens() {
3737 for idx in [1usize, 4] {
3740 let rows = screen_keymap(idx);
3741 assert!(
3742 rows.iter().any(|(k, _)| k.contains("Enter")),
3743 "screen {idx} keymap must mention Enter (drill)"
3744 );
3745 assert!(
3746 rows.iter().any(|(k, _)| k.contains("Esc")),
3747 "screen {idx} keymap must mention Esc (close drill)"
3748 );
3749 }
3750 }
3751
3752 #[test]
3753 fn screen_keymap_lottery_advertises_rchash() {
3754 let rows = screen_keymap(3);
3755 assert!(rows.iter().any(|(k, _)| k.contains("r")));
3756 }
3757
3758 #[test]
3759 fn screen_keymap_unknown_index_is_empty_not_panic() {
3760 assert!(screen_keymap(999).is_empty());
3761 }
3762
3763 #[test]
3764 fn verbosity_rank_orders_loud_to_silent() {
3765 assert!(verbosity_rank("all") > verbosity_rank("debug"));
3766 assert!(verbosity_rank("debug") > verbosity_rank("info"));
3767 assert!(verbosity_rank("info") > verbosity_rank("warning"));
3768 assert!(verbosity_rank("warning") > verbosity_rank("error"));
3769 assert!(verbosity_rank("error") > verbosity_rank("unknown"));
3770 assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
3772 assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
3773 }
3774
3775 #[test]
3776 fn filter_command_suggestions_empty_buffer_returns_all() {
3777 let matches = filter_command_suggestions("", KNOWN_COMMANDS);
3778 assert_eq!(matches.len(), KNOWN_COMMANDS.len());
3779 }
3780
3781 #[test]
3782 fn filter_command_suggestions_prefix_matches_case_insensitive() {
3783 let matches = filter_command_suggestions("Bu", KNOWN_COMMANDS);
3784 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
3785 assert!(names.contains(&"buy-preview"));
3786 assert!(names.contains(&"buy-suggest"));
3787 assert_eq!(names.len(), 2);
3788 }
3789
3790 #[test]
3791 fn filter_command_suggestions_unknown_prefix_is_empty() {
3792 let matches = filter_command_suggestions("xyz", KNOWN_COMMANDS);
3793 assert!(matches.is_empty());
3794 }
3795
3796 #[test]
3797 fn filter_command_suggestions_uses_first_token_only() {
3798 let matches = filter_command_suggestions("topup-preview a1b2 1000", KNOWN_COMMANDS);
3801 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
3802 assert_eq!(names, vec!["topup-preview"]);
3803 }
3804
3805 #[test]
3806 fn probe_chunk_is_4104_bytes_with_correct_span() {
3807 let chunk = build_synthetic_probe_chunk();
3809 assert_eq!(chunk.len(), 4104);
3810 let span = u64::from_le_bytes(chunk[..8].try_into().unwrap());
3811 assert_eq!(span, 4096);
3812 }
3813
3814 #[test]
3815 fn probe_chunk_payloads_are_unique_per_call() {
3816 let a = build_synthetic_probe_chunk();
3821 std::thread::sleep(Duration::from_micros(1));
3823 let b = build_synthetic_probe_chunk();
3824 assert_ne!(&a[8..24], &b[8..24]);
3825 }
3826
3827 #[test]
3828 fn short_hex_truncates_with_ellipsis() {
3829 assert_eq!(short_hex("a1b2c3d4e5f6", 8), "a1b2c3d4…");
3830 assert_eq!(short_hex("short", 8), "short");
3831 assert_eq!(short_hex("abcdefgh", 8), "abcdefgh");
3832 }
3833}