28 KiB
Phase 2: Interactive TUI - Research
Researched: 2026-03-21 Domain: Terminal UI with ratatui, async event handling, connection table rendering Confidence: HIGH
Summary
Phase 2 replaces the Phase 1 streaming stdout output with a full interactive TUI using ratatui 0.30 and crossterm 0.29. The existing codebase provides a clean integration surface: the tokio select! event loop in main.rs already separates collector events from periodic ticks, and the aggregator's tick() method returns exactly the data needed for rendering. The primary work is building the TUI layer (terminal init, keyboard handling, table rendering with sort/filter state) and wiring clap CLI args for filtering flags.
Ratatui 0.30 provides ratatui::init() which handles raw mode, alternate screen, and panic hooks in one call. The Table widget with TableState supports row selection and scrolling natively. Crossterm 0.29's EventStream (with the event-stream feature) integrates directly into the existing tokio select! loop for non-blocking keyboard input. Column sorting, filtering, and color-coding are application-level logic built on top of these primitives.
Primary recommendation: Use ratatui 0.30 + crossterm 0.29 with EventStream for async keyboard input. Structure the TUI as a single App struct holding sort/filter/scroll state, rendered each tick by a draw() function that builds the Table widget from sorted/filtered ConnectionRecord data. Replace print_tick() with terminal.draw(|f| app.draw(f)).
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01: Default column set (8 columns): Proto, Local Addr:Port, Remote Addr:Port, PID, Process, State, Rate In, Rate Out, RTT
- D-02: Addresses displayed as combined
addr:portin a single column (e.g.,192.168.1.5:443). IPv6 abbreviated where possible. - D-03: PID and Process Name as separate columns (not combined like
nginx (1234)) - D-04: Additional columns (Bytes In/Out, Packets In/Out) available via toggle key (
c), not shown by default - D-05: Narrow terminals (< ~120 cols) handled by truncating addresses and process names with ellipsis. Columns stay, content gets trimmed.
- D-06: Two sorting methods: mnemonic single-key shortcuts (
r/R/p/n/s/t/a/A/P) and Tab to navigate column headers + Enter to sort. Press same key again to toggle asc/desc. - D-07:
/opens filter-as-you-type mode. Table filters live as you type. Matches IP, port, or process name. Esc clears the filter. Like vim's/search. - D-08: Standard keyboard shortcuts:
q/ Ctrl-C quit,?help overlay, arrows /j/kscroll rows,/filter,ctoggle columns. - D-09: No detail pane. Table is the sole view.
- D-10: Bandwidth color coding uses intensity-based single color (dim to bright). Low bandwidth = dim text, high bandwidth = bright/bold.
- D-11: New connections get subtle green background highlight for one refresh cycle. Closing connections get red background highlight for one cycle before removal.
- D-12: Pre-existing/partial connections indicated with asterisk on process name (e.g.,
nginx*). Help overlay explains the asterisk. - D-13: Header is 3-4 lines: connection counts with TCP/UDP breakdown, aggregate bandwidth in/out, separator line.
- D-14: Active sort column and filter shown in a bottom status bar (vim-style), NOT in the header. E.g.,
Sort: Rate In down Filter: nginx - D-15: Header stays pure aggregate stats. Status bar at bottom for UI state.
Claude's Discretion
- Exact ratatui widget composition and layout structure
- Terminal initialization/restoration details
- Internal event loop architecture (how TUI rendering interleaves with data updates)
- Column width ratios and resize behavior
- Exact intensity gradient thresholds for bandwidth display
- Help overlay content and layout
- How to integrate clap CLI args with the existing main.rs event loop
Deferred Ideas (OUT OF SCOPE)
- UDP flow idle timeout configurable via CLI flag -- Phase 3 or backlog
--batchor--oncemode preserving stdout output -- evaluate in Phase 3- Reverse DNS resolution for remote IPs -- v2 requirement (DISP-V2-01)
- Connection duration/age display -- v2 requirement (DISP-V2-02)
- Freeze/pause display with 'p' key -- v2 requirement (DISP-V2-03) </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| DISP-01 | User sees a real-time sortable table with one row per connection showing all stats | Ratatui Table + TableState with custom sort logic on ConnectionRecord fields |
| DISP-02 | User sees a summary header with total connection count and aggregate bandwidth in/out | Ratatui Paragraph widget in a Layout::vertical chunk above the table |
| DISP-03 | User can sort the table by any column via keyboard | App-level sort state (column enum + direction) applied before rendering; dual input model (mnemonic keys + Tab/Enter) |
| DISP-04 | User sees local address, local port, remote address, remote port per connection | Format as addr:port string from ConnectionKey fields per D-02 |
| DISP-05 | User can configure refresh interval via CLI flag (default 1 second) | clap derive struct with --interval flag, passed to tokio::time::interval() |
| DISP-06 | Connections are color-coded by bandwidth usage | Ratatui Style with intensity-based color (dim/normal/bold) applied per-row based on rate_in + rate_out |
| DISP-07 | User can search/filter the live view with '/' key | App input mode state machine (Normal / Filter); filter string applied to records before sort+render |
| DISP-08 | User can quit with 'q' or Ctrl-C | Crossterm key event matching in the event handler |
| FILT-01 | User can filter by port via CLI flag (--port) |
clap --port arg parsed and applied as pre-filter on ConnectionTable::tick() results |
| FILT-02 | User can filter by process via CLI flag (--pid or --process) |
clap --pid and --process args |
| FILT-03 | User can select network interface via CLI flag (--interface) |
clap --interface arg; needs to be passed to collector at construction time |
| FILT-04 | User can filter by protocol via CLI flag (--tcp / --udp) |
clap boolean flags |
| </phase_requirements> |
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| ratatui | 0.30.0 | Terminal UI framework | Community standard Rust TUI. Immediate-mode rendering, Table widget with TableState, built-in init()/restore() for terminal lifecycle. |
| crossterm | 0.29.0 | Terminal backend + event input | Default backend for ratatui 0.30. EventStream (with event-stream feature) provides async Stream for tokio integration. |
| clap | 4.6.x | CLI argument parsing | Already in workspace deps with derive feature. Needed for --port, --pid, --process, --interface, --tcp, --udp, --interval flags. |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| futures | 0.3.x | StreamExt trait for EventStream::next() |
Required to .next().await on crossterm's EventStream in tokio select. |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| crossterm EventStream | crossterm::event::poll() in a blocking loop | Poll requires a dedicated thread or busy-waiting; EventStream integrates cleanly with the existing tokio select! |
| Manual terminal init | ratatui::init() | Manual gives more control but init() handles raw mode + alternate screen + panic hook in one call. Use init(). |
Installation (add to workspace Cargo.toml):
[workspace.dependencies]
ratatui = { version = "0.30.0", features = ["crossterm"] }
crossterm = { version = "0.29.0", features = ["event-stream"] }
futures = "0.3"
Add to tcptop/Cargo.toml:
[dependencies]
ratatui = { workspace = true }
crossterm = { workspace = true }
futures = { workspace = true }
Architecture Patterns
Recommended Project Structure
tcptop/src/
├── main.rs # Entry point, clap parsing, terminal init, event loop
├── lib.rs # Module declarations
├── tui/
│ ├── mod.rs # Re-exports
│ ├── app.rs # App state struct (sort, filter, scroll, input mode)
│ ├── draw.rs # Rendering: header, table, status bar, help overlay
│ └── event.rs # Keyboard event -> Action mapping
├── aggregator.rs # Unchanged from Phase 1
├── model.rs # Unchanged (may add sort helpers)
├── output.rs # Kept for non-TUI use; format helpers reused by tui/draw.rs
├── collector/ # Unchanged from Phase 1
└── ...
Pattern 1: App State Struct
What: A single App struct owns all TUI state separate from domain data.
When to use: Always -- keeps rendering pure and testable.
pub enum InputMode {
Normal,
Filter,
}
pub enum SortColumn {
Proto, LocalAddr, RemoteAddr, Pid, Process, State, RateIn, RateOut, Rtt,
BytesIn, BytesOut, PacketsIn, PacketsOut,
}
pub struct App {
pub sort_column: SortColumn,
pub sort_ascending: bool,
pub filter_text: String,
pub input_mode: InputMode,
pub table_state: TableState, // ratatui scroll/selection state
pub show_extra_columns: bool, // 'c' toggle per D-04
pub show_help: bool, // '?' overlay per D-08
pub selected_header_col: usize, // Tab navigation per D-06
// Transient display state for D-11 (new/closing highlights)
pub new_connections: HashSet<ConnectionKey>, // green highlight this tick
pub closing_connections: HashSet<ConnectionKey>, // red highlight this tick
}
Pattern 2: Async Event Loop with EventStream
What: Integrate crossterm keyboard events into the existing tokio select! loop. When to use: This is the pattern for the main loop.
use crossterm::event::EventStream;
use futures::StreamExt;
let mut event_stream = EventStream::new();
loop {
tokio::select! {
Some(event) = rx.recv() => {
table.update(event);
}
_ = tick.tick() => {
let (active, closed) = table.tick();
// Update highlight sets for D-11
app.update_highlights(&active, &closed);
// Apply CLI filters (FILT-01..04), then live filter (D-07), then sort (D-06)
let filtered = app.filter_and_sort(&active);
terminal.draw(|f| app.draw(f, &filtered))?;
}
Some(Ok(crossterm_event)) = event_stream.next() => {
if app.handle_event(crossterm_event) == Action::Quit {
break;
}
}
_ = sigint.recv() => break,
_ = sigterm.recv() => break,
}
}
Pattern 3: Layout Composition (3 regions)
What: Vertical layout with header, table, and status bar.
When to use: The main draw() function.
fn draw(&mut self, frame: &mut Frame, connections: &[&ConnectionRecord]) {
let chunks = Layout::vertical([
Constraint::Length(4), // Summary header (D-13)
Constraint::Min(5), // Connection table (DISP-01)
Constraint::Length(1), // Status bar (D-14)
]).split(frame.area());
self.draw_header(frame, chunks[0], connections);
self.draw_table(frame, chunks[1], connections);
self.draw_status_bar(frame, chunks[2]);
if self.show_help {
self.draw_help_overlay(frame, frame.area()); // Centered popup
}
}
Pattern 4: Filter-as-you-type State Machine
What: Input mode switching for the / filter.
When to use: Keyboard event handler.
fn handle_event(&mut self, event: CrosstermEvent) -> Action {
if let CrosstermEvent::Key(key) = event {
if key.kind != KeyEventKind::Press { return Action::Continue; }
match self.input_mode {
InputMode::Normal => match key.code {
KeyCode::Char('q') => return Action::Quit,
KeyCode::Char('/') => self.input_mode = InputMode::Filter,
KeyCode::Char('r') => self.toggle_sort(SortColumn::RateIn),
KeyCode::Char('R') => self.toggle_sort(SortColumn::RateOut),
// ... other mnemonic keys per D-06
KeyCode::Tab => self.select_next_header_col(),
KeyCode::Enter => self.sort_by_selected_header(),
KeyCode::Char('j') | KeyCode::Down => self.table_state.select_next(),
KeyCode::Char('k') | KeyCode::Up => self.table_state.select_previous(),
KeyCode::Char('c') => self.show_extra_columns = !self.show_extra_columns,
KeyCode::Char('?') => self.show_help = !self.show_help,
_ => {}
},
InputMode::Filter => match key.code {
KeyCode::Esc => {
self.filter_text.clear();
self.input_mode = InputMode::Normal;
}
KeyCode::Char(c) => self.filter_text.push(c),
KeyCode::Backspace => { self.filter_text.pop(); }
KeyCode::Enter => self.input_mode = InputMode::Normal,
_ => {}
},
}
}
Action::Continue
}
Anti-Patterns to Avoid
- Blocking event reads in async context: Never use
crossterm::event::read()in a tokio task. UseEventStreamwith theevent-streamfeature instead. Blocking reads will freeze the entire async runtime. - Rendering on every keypress: Only render on tick intervals. Keyboard events update state; the next tick renders the new state. This keeps rendering at a consistent cadence and avoids wasted frames.
- Forgetting terminal restore on all exit paths: Use
ratatui::init()which installs a panic hook. But also ensureratatui::restore()is called on normal exit AND on signal handlers. Wrap the main loop in a function and call restore in a defer-like pattern. - Processing Key Release/Repeat events: Crossterm sends Press, Release, and Repeat events. Filter to
KeyEventKind::Pressonly, or you get double-handling on some terminals.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Terminal raw mode / alternate screen | Manual crossterm commands | ratatui::init() / ratatui::restore() |
Handles raw mode, alternate screen, and panic hooks. One call. |
| Async keyboard input | Polling thread or busy loop | crossterm EventStream + futures StreamExt |
Integrates into tokio select! cleanly |
| Table scrolling / selection | Manual offset tracking | TableState (ratatui) |
Handles scroll position, selection, wrapping |
| Column layout constraints | Manual character counting | Constraint::Percentage, Constraint::Min, Constraint::Length |
Ratatui's layout engine handles terminal resize |
| Help overlay popup | Manual screen save/restore | Render a Clear + Block + Paragraph on top |
Ratatui's immediate-mode rendering makes overlays trivial |
Common Pitfalls
Pitfall 1: Terminal not restored after panic or early exit
What goes wrong: If the program panics or exits without restoring the terminal, the user's shell is left in raw mode with the alternate screen active. They see garbled input.
Why it happens: Manual terminal setup without panic hook. Signal handlers that exit without restoring.
How to avoid: Use ratatui::init() which installs a panic hook. Call ratatui::restore() before every exit path. In signal handlers, restore before breaking.
Warning signs: Testing crashes leave terminal in bad state.
Pitfall 2: EventStream requires the event-stream feature
What goes wrong: crossterm::event::EventStream does not exist at compile time.
Why it happens: The event-stream feature on crossterm is not enabled by default.
How to avoid: Add features = ["event-stream"] to crossterm dependency.
Warning signs: Compilation error about missing EventStream type.
Pitfall 3: Double key events (Press + Release)
What goes wrong: Each keypress triggers the handler twice, causing sort toggles to flip back, filter chars to double, etc.
Why it happens: Some terminals send both Press and Release events. Crossterm 0.28+ enables KeyEventKind reporting.
How to avoid: Filter events: if key.kind != KeyEventKind::Press { return; }
Warning signs: Sort direction flipping back immediately, double characters in filter.
Pitfall 4: TableState selection out of bounds after filter
What goes wrong: Selected row index points past the end of the filtered list, causing panic or rendering glitch.
Why it happens: User has row 15 selected, then types a filter that reduces the list to 3 items.
How to avoid: After filtering, clamp table_state.selected() to filtered.len().saturating_sub(1). Or call table_state.select(Some(0)) when filter text changes.
Warning signs: Panic on draw, or table showing no highlight.
Pitfall 5: Forgetting to handle Ctrl-C separately from 'q'
What goes wrong: Ctrl-C does not quit the app because raw mode intercepts it.
Why it happens: In raw mode, Ctrl-C is delivered as KeyCode::Char('c') with KeyModifiers::CONTROL, not as SIGINT.
How to avoid: Match on KeyCode::Char('c') when key.modifiers.contains(KeyModifiers::CONTROL) in the event handler. The existing SIGINT handler in tokio::select! is a backup but the crossterm event arrives first.
Warning signs: User presses Ctrl-C and nothing happens.
Pitfall 6: Rendering performance with many connections
What goes wrong: Drawing hundreds of rows causes noticeable lag.
Why it happens: Building Row/Cell widgets for connections not visible on screen.
How to avoid: Only build Row widgets for visible rows (use TableState::offset() + terminal height to calculate visible range). For < 500 connections this is unlikely to matter, but good to keep in mind.
Warning signs: Noticeable frame drops with > 200 connections.
Pitfall 7: ratatui 0.30 breaking change -- HorizontalAlignment
What goes wrong: Code using Alignment::Center does not compile.
Why it happens: Ratatui 0.30 renamed Alignment to HorizontalAlignment.
How to avoid: Use HorizontalAlignment::Center or import the renamed type.
Warning signs: Compilation error about Alignment not found.
Code Examples
Terminal Lifecycle (init + restore)
// Source: https://docs.rs/ratatui/latest/ratatui/fn.init.html
use ratatui::DefaultTerminal;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// init() enables raw mode, alternate screen, installs panic hook
let mut terminal = ratatui::init();
let result = run(&mut terminal).await;
// restore() disables raw mode, leaves alternate screen
ratatui::restore();
result
}
Table Widget with Styled Rows
// Source: https://ratatui.rs/examples/widgets/table/
use ratatui::widgets::{Row, Table, TableState, Block, Borders};
use ratatui::style::{Style, Color, Modifier};
use ratatui::layout::Constraint;
fn build_table<'a>(records: &'a [&ConnectionRecord], app: &App) -> Table<'a> {
let header = Row::new(vec!["Proto", "Local", "Remote", "PID", "Process", "State", "Rate In", "Rate Out", "RTT"])
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(1);
let rows: Vec<Row> = records.iter().map(|r| {
let style = bandwidth_style(r.rate_in + r.rate_out);
Row::new(vec![
proto_str(r),
format!("{}:{}", r.key.local_addr, r.key.local_port),
format!("{}:{}", r.key.remote_addr, r.key.remote_port),
r.pid.to_string(),
process_display(r), // adds * for is_partial per D-12
state_str(r),
format_rate(r.rate_in),
format_rate(r.rate_out),
format_rtt(r.rtt_us),
]).style(style)
}).collect();
let widths = [
Constraint::Length(5), // Proto
Constraint::Min(15), // Local
Constraint::Min(15), // Remote
Constraint::Length(7), // PID
Constraint::Length(12), // Process
Constraint::Length(12), // State
Constraint::Length(10), // Rate In
Constraint::Length(10), // Rate Out
Constraint::Length(8), // RTT
];
Table::new(rows, widths)
.header(header)
.block(Block::default().borders(Borders::NONE))
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED))
}
Bandwidth Intensity Styling (D-10)
fn bandwidth_style(total_rate: f64) -> Style {
if total_rate < 1024.0 {
// < 1 KB/s: dim
Style::default().fg(Color::DarkGray)
} else if total_rate < 100.0 * 1024.0 {
// 1-100 KB/s: normal
Style::default().fg(Color::White)
} else if total_rate < 1024.0 * 1024.0 {
// 100KB-1MB/s: bright
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
} else {
// > 1 MB/s: bright + underline for emphasis
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
}
}
Connection Highlight for New/Closing (D-11)
fn row_style(record: &ConnectionRecord, app: &App) -> Style {
if app.new_connections.contains(&record.key) {
Style::default().bg(Color::DarkGreen)
} else if app.closing_connections.contains(&record.key) {
Style::default().bg(Color::DarkRed)
} else {
bandwidth_style(record.rate_in + record.rate_out)
}
}
Sort Implementation
fn sort_records(records: &mut Vec<&ConnectionRecord>, column: &SortColumn, ascending: bool) {
records.sort_by(|a, b| {
let ord = match column {
SortColumn::Proto => a.key.protocol.cmp(&b.key.protocol),
SortColumn::RateIn => a.rate_in.partial_cmp(&b.rate_in).unwrap_or(Ordering::Equal),
SortColumn::RateOut => a.rate_out.partial_cmp(&b.rate_out).unwrap_or(Ordering::Equal),
SortColumn::Pid => a.pid.cmp(&b.pid),
SortColumn::Process => a.process_name.cmp(&b.process_name),
SortColumn::Rtt => a.rtt_us.cmp(&b.rtt_us),
// ... other columns
_ => Ordering::Equal,
};
if ascending { ord } else { ord.reverse() }
});
}
Filter Implementation (D-07)
fn filter_records<'a>(
records: &[&'a ConnectionRecord],
filter: &str,
cli_filters: &CliFilters,
) -> Vec<&'a ConnectionRecord> {
records.iter()
.filter(|r| {
// CLI filters (FILT-01..04)
if let Some(port) = cli_filters.port {
if r.key.local_port != port && r.key.remote_port != port { return false; }
}
if let Some(pid) = cli_filters.pid {
if r.pid != pid { return false; }
}
if let Some(ref proc) = cli_filters.process {
if !r.process_name.contains(proc.as_str()) { return false; }
}
if cli_filters.tcp_only && r.key.protocol != Protocol::Tcp { return false; }
if cli_filters.udp_only && r.key.protocol != Protocol::Udp { return false; }
// Live filter (DISP-07)
if !filter.is_empty() {
let haystack = format!(
"{} {} {} {} {}",
r.key.local_addr, r.key.local_port,
r.key.remote_addr, r.key.remote_port,
r.process_name
);
if !haystack.to_lowercase().contains(&filter.to_lowercase()) { return false; }
}
true
})
.copied()
.collect()
}
Clap Derive Struct
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "tcptop", about = "Real-time per-connection network monitor")]
pub struct Cli {
/// Filter by port (matches source or destination)
#[arg(long)]
pub port: Option<u16>,
/// Filter by process ID
#[arg(long)]
pub pid: Option<u32>,
/// Filter by process name (substring match)
#[arg(long)]
pub process: Option<String>,
/// Network interface to monitor
#[arg(long, short = 'i')]
pub interface: Option<String>,
/// Show only TCP connections
#[arg(long)]
pub tcp: bool,
/// Show only UDP connections
#[arg(long)]
pub udp: bool,
/// Refresh interval in seconds (default: 1)
#[arg(long, default_value = "1")]
pub interval: u64,
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
tui-rs crate |
ratatui (community fork) |
2023 | tui-rs is deprecated; ratatui is the only maintained option |
| Manual terminal init (enable_raw_mode + EnterAlternateScreen) | ratatui::init() |
ratatui 0.28+ | One function call handles everything including panic hook |
crossterm::event::read() blocking |
EventStream async stream |
crossterm 0.28+ with event-stream feature | Enables native tokio integration without dedicated thread |
Alignment enum |
HorizontalAlignment enum |
ratatui 0.30.0 | Renamed for clarity; old name no longer compiles |
Open Questions
-
Interface filter (FILT-03) implementation depth
- What we know:
--interfaceneeds to be passed to the collector, not just the TUI filter layer. TheLinuxCollector::new()currently takes no arguments. - What's unclear: Whether eBPF programs can be attached per-interface, or if interface filtering must happen in userspace. For Phase 2, userspace filtering of interface is likely sufficient if records include interface info.
- Recommendation: Add
--interfaceto clap args and pass to collector constructor. If eBPF per-interface attachment is complex, filter in userspace as a Phase 2 pragmatic choice and note for future optimization.
- What we know:
-
New connection detection for D-11 highlight
- What we know:
ConnectionTable::tick()returns active and closed lists. Closed connections haveis_closed = true. - What's unclear: There's no explicit "new this tick" flag on
ConnectionRecord. - Recommendation: Track connection keys seen in previous tick. Diff with current tick to identify new connections. Store previous tick's key set in App state.
- What we know:
Sources
Primary (HIGH confidence)
- Ratatui official docs - init/restore, Table, TableState, Layout, widgets
- Ratatui Table example - Table widget usage pattern with TableState
- Ratatui v0.30.0 release notes - Breaking changes (HorizontalAlignment rename), modular workspace
- Crossterm EventStream docs - Async event stream for tokio
- Ratatui async event stream tutorial - tokio::select! integration pattern
- Ratatui init() docs - Terminal lifecycle management
Secondary (MEDIUM confidence)
- Ratatui async-template - Component-based architecture reference
- Crossterm event-stream-tokio example - EventStream usage
Tertiary (LOW confidence)
- None
Metadata
Confidence breakdown:
- Standard stack: HIGH - ratatui 0.30 and crossterm 0.29 are verified current releases with well-documented APIs
- Architecture: HIGH - async event loop pattern is documented in official ratatui tutorials and matches existing codebase structure
- Pitfalls: HIGH - double key events, terminal restore, and EventStream feature flag are well-documented common issues
Research date: 2026-03-21 Valid until: 2026-04-21 (ratatui ecosystem is mature and stable)