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_foris pure (notheme::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 warningsclean - 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 indraw. 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.