Adding a screen

A practical walkthrough of adding a new screen to the cockpit. The workflow is the same one every existing screen followed: snapshot type → watcher → component → pure view fn → insta tests → wire into App.

The example in this page is hypothetical — adding an "S15 — Settlements forensics" screen on top of the current 14. Real index 10 is already Manifest, 11 is Watchlist, 12 is FeedTimeline, 13 is Pubsub; a new screen would slot in at 14. The illustrative code below uses index 14 accordingly.

1. Define the snapshot type

In src/watch/mod.rs, add a struct holding everything one poll of your endpoint produces:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Default)]
pub struct SettlementsForensicsSnapshot {
    pub last_update: Option<Instant>,
    pub settlements: Vec<Settlement>,
    pub total_received: String,
    pub total_sent: String,
}
}

The last_update: Option<Instant> field is the convention for "did we ever poll yet?" — components use it to distinguish cold-start (Unknown) from "loaded but empty".

2. Add it to BeeWatch

Spawn a watcher task. The pattern (in src/watch/):

#![allow(unused)]
fn main() {
impl BeeWatch {
    pub fn settlements_forensics(&self) -> watch::Receiver<SettlementsForensicsSnapshot> {
        self.settlements_forensics_rx.clone()
    }
}

fn spawn_settlements_forensics_watcher(
    api: Arc<ApiClient>,
    cancel: CancellationToken,
) -> watch::Receiver<SettlementsForensicsSnapshot> {
    let (tx, rx) = watch::channel(SettlementsForensicsSnapshot::default());
    tokio::spawn(async move {
        let bee = api.bee();
        let mut interval = tokio::time::interval(Duration::from_secs(30));
        loop {
            tokio::select! {
                _ = cancel.cancelled() => break,
                _ = interval.tick() => {
                    if let Ok(s) = bee.debug().settlements().await {
                        let _ = tx.send(SettlementsForensicsSnapshot {
                            last_update: Some(Instant::now()),
                            settlements: s.peers,
                            total_received: format_bzz(s.total_received),
                            total_sent: format_bzz(s.total_sent),
                        });
                    }
                }
            }
        }
    });
    rx
}
}

Pick the cadence based on how fast the data actually changes. Settlement state changes at chain rate — 30 s is plenty.

Wire spawn_settlements_forensics_watcher into BeeWatch::start and store the receiver on BeeWatch.

3. Define the View struct

In a new file src/components/settlements_forensics.rs:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettlementsForensicsView {
    pub rows: Vec<SettlementRow>,
    pub totals: SettlementsTotals,
    pub status: SettlementsStatus,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettlementRow {
    pub peer_short: String,
    pub received: String,
    pub sent: String,
    pub net: String,
    pub net_sign: NetSign,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetSign { Positive, Negative, Zero }

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettlementsStatus {
    Unknown, // cold start
    Healthy,
    Skewed,  // some peer's |net| > threshold
}
}

The View carries display-ready data: pre-formatted strings, classified statuses, sort order. The renderer should never have to re-compute "is this row skewed" — the view fn already did it.

4. Write the pure view_for fn

#![allow(unused)]
fn main() {
pub fn view_for(snap: &SettlementsForensicsSnapshot) -> SettlementsForensicsView {
    if snap.last_update.is_none() {
        return SettlementsForensicsView {
            rows: vec![],
            totals: SettlementsTotals::default(),
            status: SettlementsStatus::Unknown,
        };
    }

    let mut rows: Vec<SettlementRow> = snap.settlements
        .iter()
        .map(SettlementRow::from)
        .collect();
    rows.sort_by_key(|r| Reverse(r.abs_net_plur()));

    let any_skewed = rows.iter().any(|r| r.is_skewed());

    SettlementsForensicsView {
        rows,
        totals: SettlementsTotals { /* ... */ },
        status: if any_skewed { Skewed } else { Healthy },
    }
}
}

Pure: takes &Snapshot, returns View. No I/O, no references to global state, no theme calls. This is the testable surface.

5. Write insta snapshot tests

In tests/s11_settlements_forensics.rs:

#![allow(unused)]
fn main() {
use bee_tui::components::settlements_forensics::*;
use bee_tui::watch::SettlementsForensicsSnapshot;
use std::time::Instant;

fn fixture(/* parameters */) -> SettlementsForensicsSnapshot {
    SettlementsForensicsSnapshot {
        last_update: Some(Instant::now()),
        settlements: vec![/* fixture data */],
        total_received: "BZZ 12.5".into(),
        total_sent: "BZZ 11.2".into(),
    }
}

#[test]
fn cold_start_is_unknown() {
    let snap = SettlementsForensicsSnapshot::default();
    let view = view_for(&snap);
    assert_eq!(view.status, SettlementsStatus::Unknown);
}

#[test]
fn skewed_when_one_peer_is_far_out_of_balance() {
    let snap = fixture(/* ... */);
    let view = view_for(&snap);
    insta::assert_yaml_snapshot!(view);
}
}

Run cargo test --test s11_settlements_forensics and use cargo insta review to accept the new snapshots. The snapshots become the contract — any future change that alters the View needs explicit re-acceptance.

6. Implement the Component

#![allow(unused)]
fn main() {
pub struct SettlementsForensics {
    rx: watch::Receiver<SettlementsForensicsSnapshot>,
    snapshot: SettlementsForensicsSnapshot,
    selected: usize,
    scroll_offset: usize,
}

impl SettlementsForensics {
    pub fn new(rx: watch::Receiver<SettlementsForensicsSnapshot>) -> Self {
        let snapshot = rx.borrow().clone();
        Self { rx, snapshot, selected: 0, scroll_offset: 0 }
    }
}

impl Component for SettlementsForensics {
    fn update(&mut self, action: Action) -> Result<Option<Action>> {
        match action {
            Action::Tick => self.snapshot = self.rx.borrow().clone(),
            // handle screen-specific keys here
            _ => {}
        }
        Ok(None)
    }

    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
        let view = view_for(&self.snapshot);
        // render view into ratatui widgets
        Ok(())
    }
}
}

7. Wire into App

In src/app.rs:

#![allow(unused)]
fn main() {
const SCREEN_NAMES: &[&str] = &[
    "Health", "Stamps", "Swap", "Lottery", "Peers",
    "Network", "Warmup", "API", "Tags", "Pins",
    "Manifest", "Watchlist", "FeedTimeline", "Pubsub",
    "Settlements",  // NEW — index 14
];

fn build_screens(
    api: &Arc<ApiClient>,
    watch: &BeeWatch,
    market_rx: Option<watch::Receiver<crate::economics_oracle::EconomicsSnapshot>>,
) -> Vec<Box<dyn Component>> {
    // ...existing 14 screens...
    let settlements_forensics = SettlementsForensics::new(
        watch.settlements_forensics(),
    );
    vec![
        // ...existing...
        Box::new(settlements_forensics),
    ]
}
}

If your screen has screen-specific keys, add them to screen_keymap():

#![allow(unused)]
fn main() {
fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
    match active_screen {
        // ...existing...
        14 => &[
            ("↑↓ / j k", "scroll one row"),
            ("PgUp / PgDn", "scroll ten rows"),
        ],
        _ => &[],
    }
}
}

If your screen needs a verb category (so it appears under the right heading in the v1.10 paged help overlay), update verb_category() in src/app.rs too — the test verb_category_covers_every_known_command will fail loudly if you add a new KNOWN_COMMANDS entry without categorising it.

8. (Optional) Add a :settlements jump command

In the command bar handler in src/app.rs, the SCREEN_NAMES table makes :settlements automatically work — any name in the list becomes a valid screen-jump command. So nothing to add.

9. Add a screens entry in mdBook

Edit docs/book/src/SUMMARY.md:

- [S11 — Settlements forensics](./screens/s11-settlements.md)

Then write docs/book/src/screens/s11-settlements.md following the existing pattern: "Why this screen exists → data shape → status semantics → common scenarios → snapshot cadence → keys".

Checklist

Before opening a PR:

  • Watcher task respects cancel.cancelled() so it shuts down cleanly
  • Watcher cadence is appropriate (don't poll faster than data actually changes)
  • view_for is pure (no theme::active() calls; let the renderer do colour)
  • insta tests cover cold-start (Unknown) + healthy + at least one degraded state
  • cargo fmt && cargo clippy --all-targets --all-features -- -D warnings clean
  • mdBook page added to SUMMARY.md
  • If your screen has interactive keys, they're listed in screen_keymap() so the ? overlay finds them

Things to not do

  • Don't poll inside a Component. Components are pure renderers. Move polling to the watch hub.
  • Don't share mutable state between Components. Use the watch hub if multiple screens need the same data.
  • Don't compute layout / colour inside view_for. That belongs in draw. The View is data, the renderer is presentation.
  • Don't skip insta tests even if the screen "looks simple". The investment pays off the first time someone refactors the cockpit's wiring.