Skip to content

feat(leios-tui): add terminal dashboard for the Leios testnet#791

Merged
scarmuega merged 2 commits into
mainfrom
feat/leios-tui
Jul 1, 2026
Merged

feat(leios-tui): add terminal dashboard for the Leios testnet#791
scarmuega merged 2 commits into
mainfrom
feat/leios-tui

Conversation

@scarmuega

@scarmuega scarmuega commented Jul 1, 2026

Copy link
Copy Markdown
Member

What

Adds a new leios-tui example: a live terminal dashboard that turns a Leios initiator node into an educational visualization of the overlay.

It runs the same flow as the leios-testnet example — negotiate the v15 (Leios) handshake, follow Praos chain-sync, and fetch Endorser Blocks over leios-notify / leios-fetch — but renders it as a ratatui TUI instead of log lines.

The view

Ranking blocks (RBs) and endorser blocks (EBs) render as boxes in two vertically-aligned swim lanes sharing one column axis: the newest RBs define the columns (newest at the tip, right), and each EB is drawn in the column of the RB it belongs to — so the shared column is the connection. The window slides as the tip advances.

  • RB boxes (small) show the block number, a derived block hash (blake2b of the header CBOR) and the slot.
  • EB boxes (tall) show slot, a tx-download bar and a vote bar (label + value above each full-width bar), and a per-phase checklist (offered / body / download txs / votes) flagged with done, in progress, pending.
  • Selecting an EB highlights its box and its RB column; a detail strip shows the selected EB's full figures.

On the EB→RB association

There is no on-wire link between an EB and its RB today: leios_cert in the Dijkstra header is an empty placeholder and BlockAnnouncement is unspecified (any). So the column assignment is a slot heuristic (nearest RB with slot >= eb_slot), clearly labelled as such in the footer. Decoding BlockAnnouncement for a real link is left as a documented hook for when the devnet diffuses announcements.

The vote bar is likewise self-calibrating (voters against the peak seen), since votes carry no stake weight and there is no committee/quorum on the wire; real quorum is stake-weighted τ≈75% per CIP-0164.

Layout

  • src/main.rs — network setup + the tokio::select! loop (housekeeping, render tick, network events, keyboard input).
  • src/dashboard.rs — network-free view state; folds InitiatorEvents into state, emits follow-up Actions, and performs RB retention + EB→RB linking.
  • src/logbuf.rs — a tracing layer capturing recent log lines into an in-memory buffer for the log panel.
  • src/ui.rs — the aligned swim-lane ratatui rendering (unit-tested headlessly via TestBackend).

Also registers the example as a workspace member in the root Cargo.toml and adds a pallas-crypto dependency for RB hashing.

Run with:

```sh
cargo run -p leios-tui
```

Keys: `q` quit · `←`/`→` select EB · `f` toggle follow-tip · `c` clear log.

Notes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a new terminal UI example that connects to a Leios testnet node and displays live chain, network, and overlay activity.
    • Introduced a full-screen initiator dashboard with endorser block tracking, vote counts, progress indicators, and an in-app scrolling log panel.
    • Refreshed terminal rendering to use aligned “RB/EB” lanes with clearer selection/follow behavior and richer per-block details.
  • Tests
    • Added automated coverage for dashboard lifecycle, retention limits, vote tracking, and rendering stability across terminal sizes.

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a new example crate leios-tui: a terminal UI application for a Leios initiator peer. It registers the crate in the workspace, adds a bounded tracing log buffer, a dashboard state machine with tests, a ratatui renderer, and an async main loop that connects to the relay and drives the UI.

Changes

Leios TUI example

Layer / File(s) Summary
Crate manifest and workspace registration
Cargo.toml, examples/leios-tui/Cargo.toml
Registers examples/leios-tui in workspace members and defines its package metadata and dependencies.
Log buffering tracing layer
examples/leios-tui/src/logbuf.rs
Adds SharedLog, LogLayer, an uptime helper, and a visitor that formats tracing event fields into bounded ring-buffer log lines.
Dashboard state model and event state machine
examples/leios-tui/src/dashboard.rs
Defines the dashboard state types and implements event handling for chain-sync and Leios overlay updates, including fetch actions, EB lifecycle transitions, vote tracking, retention, and input handling.
Dashboard unit tests
examples/leios-tui/src/dashboard.rs
Tests EB lifecycle transitions, vote attribution, tx-window paging, RB strip retention, and out-of-order EB to RB linking.
Ratatui rendering of dashboard state
examples/leios-tui/src/ui.rs
Implements the draw function and renderers for the header, RB lane, EB lane, log panel, and footer, plus selection mapping, lifecycle indicators, progress bars, and formatting helpers.
Main entry point and async event loop
examples/leios-tui/src/main.rs
Adds the LeiosNode wrapper, network/config constants, dashboard action translation, the async run loop, and main setup for logging, terminal state, and chain sync.

Estimated code review effort: 3 (Moderate) | ~30 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the new leios-tui terminal dashboard example for the Leios testnet.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/leios-tui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
examples/leios-tui/src/logbuf.rs (1)

45-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding unit tests for LogLayer/uptime formatting.

Unlike dashboard.rs, this file has no tests covering line formatting, ring-buffer trimming at LOG_CAP, or the message/field extraction logic. A lightweight test using a manually constructed tracing::Subscriber (or calling MsgVisitor/uptime directly) would guard against future formatting regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/leios-tui/src/logbuf.rs` around lines 45 - 66, Add unit tests for
LogLayer and uptime formatting to cover the current line construction in
LogLayer::on_event. Create a lightweight test that exercises MsgVisitor plus
uptime (or a minimal tracing::Subscriber/Event path) and verifies the formatted
output includes the expected timestamp/level/message/fields. Also add a test
that pushes more than LOG_CAP entries through LogLayer’s buffer path and asserts
the ring buffer trims oldest entries correctly.
examples/leios-tui/src/main.rs (1)

167-170: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No panic hook to restore terminal state.

ratatui::restore() at Line 169 only runs on normal return from node.run(). A panic inside run() (e.g., in handle_event, dashboard state, or ui::draw) will skip the restore, leaving the terminal in raw/alternate-screen mode after the process exits.

🔧 Proposed fix
     let mut terminal = ratatui::init();
+    let default_hook = std::panic::take_hook();
+    std::panic::set_hook(Box::new(move |info| {
+        ratatui::restore();
+        default_hook(info);
+    }));
     let result = node.run(&mut terminal).await;
     ratatui::restore();
     result
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/leios-tui/src/main.rs` around lines 167 - 170, The terminal cleanup
in main only happens after node.run(&mut terminal).await returns normally, so a
panic in node.run, handle_event, or ui::draw can skip ratatui::restore and leave
the terminal broken. Update the startup/shutdown flow around ratatui::init and
node.run to guarantee restoration on both normal exit and panic, such as by
installing a panic hook or using a guard that always calls ratatui::restore
before the process unwinds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/leios-tui/src/dashboard.rs`:
- Around line 218-225: The `EbFetched::Block` handling in `dashboard.rs` only
updates `tx_total` and advances the row, so if `BlockTxsOffer` arrived first the
offer is never acted on. Update the block-body path to preserve offers via
`upsert_eb` and, when `body.raw_bytes()` reveals the tx count, immediately
trigger the first tx fetch window for that existing row; also make sure the same
behavior is applied in the other body-handling block referenced by the review.
- Around line 278-282: The BlockOffer handling in dashboard.rs always queues
Action::FetchEb, which causes duplicate body requests for the same EB even when
a fetch is already in flight or the body is already present. Update the
leiosnotify::Notification::BlockOffer branch in dashboard::Dashboard logic to
check the EB state before pushing FetchEb, using the existing upsert_eb/EB
tracking fields to detect whether this is the first body offer. Only emit the
fetch action when the EB has not yet had a body request or stored body, while
keeping the offered counter and logging unchanged.

In `@examples/leios-tui/src/main.rs`:
- Around line 113-135: The input handling in the main event loop is ignoring
`Some(Err(_))` from `EventStream::next`, so failures are silently dropped.
Update the `select!` branch in `main` to explicitly handle the
`self.input.next()` result in `leios-tui::main` by matching
`Option<Result<Event, Error>>`, returning an error or otherwise surfacing the
failure when the stream yields `Err`, while keeping the existing
`dashboard.handle_input` path for valid events.

In `@examples/leios-tui/src/ui.rs`:
- Around line 186-192: The selection logic in ui.rs is using a positional index
for d.selected against d.ebs.values().rev(), so adding new EB rows can move the
highlight to the wrong item when follow is off. Update the state handling around
the table_state.select block to track the selected EbId in the UI state, or
recompute d.selected when the EB list changes so the same block remains selected
after inserts. Keep the fix centered on the selection update path that uses
d.ebs, d.follow, and table_state.select.

---

Nitpick comments:
In `@examples/leios-tui/src/logbuf.rs`:
- Around line 45-66: Add unit tests for LogLayer and uptime formatting to cover
the current line construction in LogLayer::on_event. Create a lightweight test
that exercises MsgVisitor plus uptime (or a minimal tracing::Subscriber/Event
path) and verifies the formatted output includes the expected
timestamp/level/message/fields. Also add a test that pushes more than LOG_CAP
entries through LogLayer’s buffer path and asserts the ring buffer trims oldest
entries correctly.

In `@examples/leios-tui/src/main.rs`:
- Around line 167-170: The terminal cleanup in main only happens after
node.run(&mut terminal).await returns normally, so a panic in node.run,
handle_event, or ui::draw can skip ratatui::restore and leave the terminal
broken. Update the startup/shutdown flow around ratatui::init and node.run to
guarantee restoration on both normal exit and panic, such as by installing a
panic hook or using a guard that always calls ratatui::restore before the
process unwinds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 033ef479-9177-404e-adc0-c6742a02d716

📥 Commits

Reviewing files that changed from the base of the PR and between e669148 and 2cf5a52.

📒 Files selected for processing (6)
  • Cargo.toml
  • examples/leios-tui/Cargo.toml
  • examples/leios-tui/src/dashboard.rs
  • examples/leios-tui/src/logbuf.rs
  • examples/leios-tui/src/main.rs
  • examples/leios-tui/src/ui.rs

Comment on lines +218 to +225
leiosfetch::Response::Block(body) => {
let n = eb_tx_count(body.raw_bytes());
self.overlay.bodies += 1;
self.overlay.bytes += body.raw_bytes().len() as u64;
if let Some(row) = self.ebs.get_mut(eb) {
row.tx_total = Some(n);
row.advance(EbStage::BodyFetched);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fetch txs after the body arrives when the tx offer came first.

If BlockTxsOffer arrives before EbFetched::Block, the row reaches TxsOffered, but the body handler only records tx_total and returns no action, so transactions are never fetched. Preserve tx offers with upsert_eb, then trigger the first tx window when the body later provides the count.

Proposed fix
                 leiosfetch::Response::Block(body) => {
                     let n = eb_tx_count(body.raw_bytes());
+                    let mut txs_fetch = None;
                     self.overlay.bodies += 1;
                     self.overlay.bytes += body.raw_bytes().len() as u64;
                     if let Some(row) = self.ebs.get_mut(eb) {
+                        let txs_were_offered = row.stage == EbStage::TxsOffered;
                         row.tx_total = Some(n);
                         row.advance(EbStage::BodyFetched);
+                        if txs_were_offered && n > 0 {
+                            txs_fetch = Some(Bitmaps::all(n.min(MAX_TXS_PER_FETCH)));
+                        }
+                    }
+                    if let Some(bitmaps) = txs_fetch {
+                        actions.push(Action::FetchEbTxs(pid.clone(), eb.clone(), bitmaps));
                     }
                     tracing::info!(eb = %fmt_eb(eb), bytes = body.raw_bytes().len(), txs = n, "eb body fetched");
                 }
             leiosnotify::Notification::BlockTxsOffer(eb) => {
                 self.overlay.txs_offered += 1;
-                let total = self.ebs.get(eb).and_then(|r| r.tx_total);
-                if let Some(row) = self.ebs.get_mut(eb) {
+                let total = {
+                    let row = self.upsert_eb(eb);
                     row.advance(EbStage::TxsOffered);
-                }
+                    row.tx_total
+                };
                 match total {

Also applies to: 284-303

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/leios-tui/src/dashboard.rs` around lines 218 - 225, The
`EbFetched::Block` handling in `dashboard.rs` only updates `tx_total` and
advances the row, so if `BlockTxsOffer` arrived first the offer is never acted
on. Update the block-body path to preserve offers via `upsert_eb` and, when
`body.raw_bytes()` reveals the tx count, immediately trigger the first tx fetch
window for that existing row; also make sure the same behavior is applied in the
other body-handling block referenced by the review.

Comment on lines +278 to +282
leiosnotify::Notification::BlockOffer(eb, size) => {
self.overlay.offered += 1;
self.upsert_eb(eb).size = Some(*size);
actions.push(Action::FetchEb(pid.clone(), eb.clone()));
tracing::info!(eb = %fmt_eb(eb), size, "eb offered → fetching body");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Avoid refetching EB bodies on duplicate offers.

BlockOffer always emits FetchEb, even for an EB row that already has a body request in flight or a fetched body. Gate the fetch on the first body offer to avoid redundant network calls.

Proposed fix
             leiosnotify::Notification::BlockOffer(eb, size) => {
                 self.overlay.offered += 1;
-                self.upsert_eb(eb).size = Some(*size);
-                actions.push(Action::FetchEb(pid.clone(), eb.clone()));
-                tracing::info!(eb = %fmt_eb(eb), size, "eb offered → fetching body");
+                let should_fetch = {
+                    let row = self.upsert_eb(eb);
+                    let should_fetch = row.size.is_none() && row.tx_total.is_none();
+                    row.size = Some(*size);
+                    should_fetch
+                };
+                if should_fetch {
+                    actions.push(Action::FetchEb(pid.clone(), eb.clone()));
+                    tracing::info!(eb = %fmt_eb(eb), size, "eb offered → fetching body");
+                } else {
+                    tracing::info!(eb = %fmt_eb(eb), size, "eb offered (already known)");
+                }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
leiosnotify::Notification::BlockOffer(eb, size) => {
self.overlay.offered += 1;
self.upsert_eb(eb).size = Some(*size);
actions.push(Action::FetchEb(pid.clone(), eb.clone()));
tracing::info!(eb = %fmt_eb(eb), size, "eb offered → fetching body");
leiosnotify::Notification::BlockOffer(eb, size) => {
self.overlay.offered += 1;
let should_fetch = {
let row = self.upsert_eb(eb);
let should_fetch = row.size.is_none() && row.tx_total.is_none();
row.size = Some(*size);
should_fetch
};
if should_fetch {
actions.push(Action::FetchEb(pid.clone(), eb.clone()));
tracing::info!(eb = %fmt_eb(eb), size, "eb offered → fetching body");
} else {
tracing::info!(eb = %fmt_eb(eb), size, "eb offered (already known)");
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/leios-tui/src/dashboard.rs` around lines 278 - 282, The BlockOffer
handling in dashboard.rs always queues Action::FetchEb, which causes duplicate
body requests for the same EB even when a fetch is already in flight or the body
is already present. Update the leiosnotify::Notification::BlockOffer branch in
dashboard::Dashboard logic to check the EB state before pushing FetchEb, using
the existing upsert_eb/EB tracking fields to detect whether this is the first
body offer. Only emit the fetch action when the EB has not yet had a body
request or stored body, while keeping the offered counter and logging unchanged.

Comment on lines +113 to +135
loop {
select! {
_ = self.housekeeping_interval.tick() => {
self.network.execute(InitiatorCommand::Housekeeping);
}
_ = self.render_interval.tick() => {
terminal.draw(|f| ui::draw(f, &self.dashboard, &mut self.table_state))?;
}
evt = self.network.poll_next() => {
if let Some(evt) = evt {
self.handle_event(evt);
}
}
ev = self.input.next() => {
if let Some(Ok(ev)) = ev
&& self.dashboard.handle_input(ev)
{
return Ok(());
}
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

pallas-network2 Manager poll_next behavior after peer disconnect

💡 Result:

In the pallas-network2 crate, the Manager struct acts as the orchestrator between a network Interface and a protocol Behavior [1][2][3]. When a peer disconnects, the behavior of Manager::poll_next is defined by how it reconciles the interface's IO events with the behavior's business logic [1][3]. The Manager::poll_next method advances the state machine by polling the network interface for events and subsequently driving the behavior [1][3]. When a peer disconnects at the transport level, the Interface yields an InterfaceEvent (typically InterfaceEvent::Disconnected) [1][4][3]. The Manager processes this event by passing it to the associated Behavior [1][2]. The Behavior is then responsible for updating its internal state (e.g., removing the peer from its registry) and may emit a corresponding event or output back to the external consumer [5][6]. Recent updates to the crate explicitly support tracking and reporting peer disconnections, where Behaviors like ResponderBehavior may emit a specific event (e.g., ResponderEvent::PeerDisconnected) to notify the application layer [5]. Consequently, when you poll the Manager, you receive these outputs emitted by the Behavior as it processes the underlying disconnection [1][5]. Essentially, Manager::poll_next does not unilaterally "handle" the disconnect by itself; rather, it facilitates the flow of the disconnection event from the Interface to the Behavior, and then surfaces any resulting outputs or events generated by the Behavior to your application's event loop [1][3].

Citations:


🌐 Web query:

crossterm EventStream next returns None when stdin closes behavior

💡 Result:

In the crossterm crate, the EventStream::next method returns Poll::Ready(None) to indicate that the stream is exhausted [1][2]. When standard input (stdin) is closed, the behavior of EventStream is not explicitly documented as a standard "stream termination" signal in the same way a file or network socket might behave [1][3]. Instead, EventStream is designed to monitor terminal input (typically via TTY) [4][3]. Because the underlying implementation relies on polling a TTY file descriptor or equivalent source, it generally expects the terminal to remain open for the duration of the application's event loop [2][4][5]. If stdin is closed (e.g., via EOF), the internal polling mechanism may simply fail to retrieve further events or, depending on the platform and configuration, cause the polling thread to encounter errors [2][6][4]. It does not inherently guarantee a graceful stream termination (yielding None) upon stdin closure [1][2]. Key points regarding EventStream behavior: 1. Stream Exhaustion: As a type implementing the futures::Stream trait, returning None is the standard way to signal that a stream will no longer produce items [1]. 2. TTY Dependency: Crossterm's event system is tightly coupled with terminal TTY access [7][4][5]. It is not designed to function as a generic wrapper for arbitrary stdin pipes, and attempting to use it in environments without a proper TTY can lead to errors or panics [7][5]. 3. Implementation Details: The EventStream internally spawns a thread to poll for events [2][6]. If the input source closes, the internal poll logic may return an error rather than a controlled termination of the stream [2][4]. For robust applications, it is often recommended to monitor the status of the input stream separately or handle potential I/O errors that occur when the underlying terminal resource becomes unavailable [3]. If you require specific behavior upon stdin closure, you may need to implement custom handling or checks before the next poll [8].

Citations:


🌐 Web query:

tokio_stream StreamExt next returns None when stream ends behavior

💡 Result:

In the tokio_stream crate, the StreamExt::next method is used to consume and return the next value in a stream [1][2]. When the stream is finished, next returns None [1][2]. Key behaviors regarding this process include: 1. Stream Termination: Receiving None from next indicates that the stream has been exhausted or terminated [3]. 2. Undefined Behavior Post-Termination: Once a stream has returned None, its behavior is generally undefined if polled further [1][4]. Future calls to poll_next may result in the stream returning Some(T) again, returning None, or even panicking, depending on the specific implementation of the stream [1][4]. 3. Fuse for Safety: If you need to ensure that a stream consistently returns None after it has finished, you should use the fuse adapter [1][4]. The fuse method ensures that after the stream returns None for the first time, all subsequent calls to next will also return None [1][4]. 4. Requirements: Because next does not take ownership of the stream, the stream type must satisfy the Unpin trait [1][2]. If a stream is not Unpin, it must be pinned (for example, using Box::pin or the pin_mut! macro) before calling next [1][2]. This behavior is typically utilized in while let loops to iterate through stream items until the stream terminates [3][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== main.rs excerpt ==\n'
sed -n '100,145p' examples/leios-tui/src/main.rs

printf '\n== Cargo manifest references ==\n'
rg -n 'crossterm|tokio-stream|pallas-network2|ratatui' -S Cargo.toml examples/leios-tui/Cargo.toml Cargo.lock || true

Repository: txpipe/pallas

Length of output: 2215


🌐 Web query:

crossterm EventStream docs None end of stream

💡 Result:

In the context of the Crossterm library, the EventStream implements the Stream trait, which defines a poll_next method that returns Poll<Option<Self::Item>> [1][2]. When this method returns None, it signifies that the stream is exhausted (i.e., the end of the stream has been reached) [1][2]. However, for a standard crossterm::event::EventStream, the stream is essentially an infinite source of terminal events and does not naturally "end" or terminate in typical usage [1][3]. If you are observing None in your code (e.g., when pattern matching on the result of reader.next().await), it usually indicates that the stream has been dropped or that a specific implementation detail of the surrounding async runtime or wrapper has caused the stream to terminate [4][5]. Key points regarding EventStream and stream exhaustion: 1. Nature of the Stream: EventStream is designed to continuously yield terminal events (keyboard, mouse, etc.) [3]. It is not a finite collection of data that naturally finishes [1]. 2. Handling None: In async code using tokio::select! or similar mechanisms, receiving None from EventStream::next() typically means the stream has been closed or dropped [4][5]. If you are seeing this unexpectedly, check your code to ensure the EventStream instance is not being dropped prematurely [6]. 3. Implementation: The poll_next implementation returns Poll::Pending when no events are currently available, keeping the stream alive [6][7]. It does not return Poll::Ready(None) under normal operation unless the underlying internal reader is no longer functional or the stream has been shut down [6]. If you are following a pattern like the one found in common tutorials (e.g., in Ratatui applications), the event loop usually runs indefinitely [5]. If your loop exits due to None, investigate where the EventStream instance is being held and if it is being dropped by a scope change or error condition [6][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('examples/leios-tui/src/main.rs')
for i, line in enumerate(p.read_text().splitlines(), 1):
    if 110 <= i <= 140:
        print(f"{i:4d}: {line}")
PY

Repository: txpipe/pallas

Length of output: 1412


🌐 Web query:

crossterm eventstream stream ends None closed stdin docs.rs

💡 Result:

In the crossterm crate, the EventStream type implements the futures::stream::Stream trait, yielding Result<Event, std::io::Error> items [1][2]. Regarding the behavior of the stream: 1. Stream End (None): When using EventStream, receiving None from the stream signifies that the stream has been exhausted or terminated [1][3][4]. In typical usage within an asynchronous event loop (e.g., using StreamExt::next), a None result is the standard indicator to break the loop [5][6]. 2. Stdin and Closed Streams: EventStream relies on the terminal's input source [2]. If the underlying input (stdin/TTY) is closed or cannot be accessed, the behavior often depends on the environment [7][8]. Historically, issues have been raised regarding EventStream panicking during initialization or polling if a valid TTY is not available, rather than gracefully returning an error or closing the stream [7][8]. Because EventStream is tightly coupled with terminal event processing, it does not function as a generic stream that simply terminates if stdin is closed; rather, it expects an active TTY connection [2][9]. To use EventStream, you must enable the event-stream feature flag in your Cargo.toml [1][2]. It is recommended to handle the Option<Result<Event>> returned by the stream by checking for both Some(Err(_)) (to handle I/O errors) and None (to handle graceful termination) [5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== poll_next definitions and stream impls ==\n'
rg -n "poll_next\\s*\\(|impl .*Stream|type Item|Disconnected|None" pallas-network2 examples/leios-tui -S

printf '\n== candidate files ==\n'
fd -a ".*(manager|stream|event|behavior).*\\.rs$" pallas-network2

Repository: txpipe/pallas

Length of output: 13815


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for path in [Path('pallas-network2'), Path('examples/leios-tui')]:
    if path.exists():
        print(f'== {path} ==')
        for p in path.rglob('*.rs'):
            txt = p.read_text(errors='ignore')
            if 'poll_next' in txt or 'Disconnected' in txt or 'impl Stream' in txt:
                print(p)
PY

Repository: txpipe/pallas

Length of output: 664


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for p in Path('pallas-network2').rglob('*.rs'):
    txt = p.read_text(errors='ignore')
    if 'poll_next' in txt or 'Disconnected' in txt or 'impl Stream' in txt:
        print(p)
PY

Repository: txpipe/pallas

Length of output: 586


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== pallas-network2/src/lib.rs (280-360) ==\n'
sed -n '280,360p' pallas-network2/src/lib.rs

printf '\n== pallas-network2/src/interface.rs (250-360) ==\n'
sed -n '250,360p' pallas-network2/src/interface.rs

printf '\n== pallas-network2/src/behavior/mod.rs (1-140) ==\n'
sed -n '1,140p' pallas-network2/src/behavior/mod.rs

Repository: txpipe/pallas

Length of output: 11850


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== pallas-network2/README.md relevant excerpts ==\n'
sed -n '30,70p' pallas-network2/README.md

printf '\n== examples/leios-tui/src/main.rs top and input init ==\n'
sed -n '1,110p' examples/leios-tui/src/main.rs

Repository: txpipe/pallas

Length of output: 5687


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for path in [Path('pallas-network2/src/lib.rs'), Path('pallas-network2/src/interface.rs'), Path('pallas-network2/src/behavior/mod.rs')]:
    print(f'\n### {path} ###')
    txt = path.read_text()
    for start in [1]:
        pass
PY

Repository: txpipe/pallas

Length of output: 274


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

def show(path, start, end):
    print(f"\n== {path} ({start}-{end}) ==")
    lines = path.read_text().splitlines()
    for i in range(start, min(end, len(lines)) + 1):
        print(f"{i:4d}: {lines[i-1]}")

show(Path('pallas-network2/src/lib.rs'), 280, 360)
show(Path('pallas-network2/src/interface.rs'), 250, 360)
show(Path('pallas-network2/src/behavior/mod.rs'), 1, 140)
PY

Repository: txpipe/pallas

Length of output: 13843


Handle input errors from EventStream. self.input.next() yields Option<Result<Event, Error>>, but the current branch drops Some(Err(_)), so terminal/input failures are silently ignored and the loop keeps running on a broken source.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/leios-tui/src/main.rs` around lines 113 - 135, The input handling in
the main event loop is ignoring `Some(Err(_))` from `EventStream::next`, so
failures are silently dropped. Update the `select!` branch in `main` to
explicitly handle the `self.input.next()` result in `leios-tui::main` by
matching `Option<Result<Event, Error>>`, returning an error or otherwise
surfacing the failure when the stream yields `Err`, while keeping the existing
`dashboard.handle_input` path for valid events.

Comment thread examples/leios-tui/src/ui.rs Outdated
Comment on lines +186 to +192
let len = d.ebs.len();
if len == 0 {
table_state.select(None);
} else {
let sel = if d.follow { 0 } else { d.selected.min(len - 1) };
table_state.select(Some(sel));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how `selected` is mutated relative to new EB insertion order.
rg -n -A5 -B5 'selected' examples/leios-tui/src/dashboard.rs

Repository: txpipe/pallas

Length of output: 1339


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect insertion order for `ebs` and surrounding selection logic.
sed -n '160,260p' examples/leios-tui/src/dashboard.rs
printf '\n---\n'
sed -n '330,390p' examples/leios-tui/src/dashboard.rs
printf '\n---\n'
sed -n '130,195p' examples/leios-tui/src/ui.rs

Repository: txpipe/pallas

Length of output: 9358


Keep the selected EB stable when new rows arrive
selected is a positional index into d.ebs.values().rev(), so inserting a new EB while follow is off can shift the highlight to a different block. Track the selected EbId instead, or adjust the index when the list changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/leios-tui/src/ui.rs` around lines 186 - 192, The selection logic in
ui.rs is using a positional index for d.selected against d.ebs.values().rev(),
so adding new EB rows can move the highlight to the wrong item when follow is
off. Update the state handling around the table_state.select block to track the
selected EbId in the UI state, or recompute d.selected when the EB list changes
so the same block remains selected after inserts. Keep the fix centered on the
selection update path that uses d.ebs, d.follow, and table_state.select.

Reframe the dashboard as an educational view of the Leios overlay:
ranking blocks and endorser blocks render as boxes in two tip-following
lanes sharing one column axis, so an EB sits under the RB it belongs to
and the shared column is the connection. The EB→RB association is a slot
heuristic (nearest following RB) since no on-wire message links them;
BlockAnnouncement decoding is left as a hook for when the devnet diffuses
announcements. EB boxes show tx-download and vote bars plus a per-phase
checklist; RB boxes show a derived block hash and slot. Also refreshes
the chain-sync intersect point to a current tip.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/leios-tui/src/ui.rs`:
- Around line 369-370: The labels in the UI are using `row.voters.len()` as raw
“votes”, which can be misleading when vote metadata is missing and causes the EB
status to disagree with the displayed count. Update the label formatting in the
`Line::from(...)` rendering for this row (and the similar block around the other
affected location) to separate raw vote totals from distinct voter counts, using
the lifecycle’s `row.votes` for votes and a distinct label for voter count. Keep
the `full_bar(row.vote_ratio(scale), ...)` rendering unchanged, and make sure
the label text in the UI reflects the actual meaning of each field.
- Line 493: The footer text in ui.rs is missing the documented Up/Down selection
keys, even though the dashboard supports them. Update the footer string in the
UI rendering logic to advertise both ↑/↓ and ←/→ selection controls, keeping the
existing follow/clear-log/help wording intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2e93c037-89f7-47f9-87f3-f344194f733d

📥 Commits

Reviewing files that changed from the base of the PR and between 2cf5a52 and 98cc3ee.

📒 Files selected for processing (4)
  • examples/leios-tui/Cargo.toml
  • examples/leios-tui/src/dashboard.rs
  • examples/leios-tui/src/main.rs
  • examples/leios-tui/src/ui.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • examples/leios-tui/Cargo.toml
  • examples/leios-tui/src/main.rs

Comment on lines +369 to +370
Line::from(format!("votes {}/{}", row.voters.len(), scale)),
full_bar(row.vote_ratio(scale), bar_w, Color::Green),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Separate raw votes from distinct voters in labels.

These lines label row.voters.len() as votes, while the lifecycle uses row.votes. If a vote lacks voter metadata, the EB can show “voting” while displaying votes 0.

Proposed label fix
-        Line::from(format!("votes {}/{}", row.voters.len(), scale)),
+        Line::from(format!("voters {}/{}", row.voters.len(), scale)),
         full_bar(row.vote_ratio(scale), bar_w, Color::Green),
-            " selected  EB {hash} · slot {} · {size} · txs {txs} · votes {} · ",
+            " selected  EB {hash} · slot {} · {size} · txs {txs} · voters {} · votes {} · ",
             group(row.slot),
-            row.voters.len()
+            row.voters.len(),
+            row.votes

Also applies to: 451-453

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/leios-tui/src/ui.rs` around lines 369 - 370, The labels in the UI
are using `row.voters.len()` as raw “votes”, which can be misleading when vote
metadata is missing and causes the EB status to disagree with the displayed
count. Update the label formatting in the `Line::from(...)` rendering for this
row (and the similar block around the other affected location) to separate raw
vote totals from distinct voter counts, using the lifecycle’s `row.votes` for
votes and a distinct label for voter count. Keep the
`full_bar(row.vote_ratio(scale), ...)` rendering unchanged, and make sure the
label text in the UI reflects the actual meaning of each field.

fn render_footer(f: &mut Frame, d: &Dashboard, area: Rect) {
let follow = if d.follow { "on" } else { "off" };
let line = Line::from(format!(
" q quit ←/→ select EB f follow:{follow} c clear log · column = nearest RB by slot (heuristic) · vote bar ∝ peak voters"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include the documented Up/Down selection keys in the footer.

The dashboard handles ↑/↓ as well as ←/→, but the footer only advertises ←/→.

-        " q quit   ←/→ select EB   f follow:{follow}   c clear log    · column = nearest RB by slot (heuristic) · vote bar ∝ peak voters"
+        " q quit   ↑/↓ or ←/→ select EB   f follow:{follow}   c clear log    · column = nearest RB by slot (heuristic) · vote bar ∝ peak voters"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
" q quit ←/→ select EB f follow:{follow} c clear log · column = nearest RB by slot (heuristic) · vote bar ∝ peak voters"
" q quit ↑/↓ or ←/→ select EB f follow:{follow} c clear log · column = nearest RB by slot (heuristic) · vote bar ∝ peak voters"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/leios-tui/src/ui.rs` at line 493, The footer text in ui.rs is
missing the documented Up/Down selection keys, even though the dashboard
supports them. Update the footer string in the UI rendering logic to advertise
both ↑/↓ and ←/→ selection controls, keeping the existing follow/clear-log/help
wording intact.

@scarmuega scarmuega merged commit cdee91d into main Jul 1, 2026
11 checks passed
@scarmuega scarmuega deleted the feat/leios-tui branch July 1, 2026 13:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant