28 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-interactive-tui | 01 | execute | 1 |
|
true |
|
|
Purpose: Transform tcptop from a streaming text dump into a usable top-like tool with a real terminal UI.
Output: Working TUI that renders live connection data in a sortable table with header stats and status bar.
<execution_context> @/Users/zrowitsch/local_src/tcptop/.claude/get-shit-done/workflows/execute-plan.md @/Users/zrowitsch/local_src/tcptop/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-interactive-tui/02-CONTEXT.md @.planning/phases/02-interactive-tui/02-RESEARCH.mdFrom tcptop/src/model.rs:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Protocol { Tcp, Udp }
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ConnectionKey {
pub protocol: Protocol,
pub local_addr: IpAddr,
pub local_port: u16,
pub remote_addr: IpAddr,
pub remote_port: u16,
}
#[derive(Debug, Clone)]
pub struct ConnectionRecord {
pub key: ConnectionKey,
pub pid: u32,
pub process_name: String,
pub tcp_state: Option<TcpState>,
pub bytes_in: u64,
pub bytes_out: u64,
pub packets_in: u64,
pub packets_out: u64,
pub rate_in: f64,
pub rate_out: f64,
pub prev_bytes_in: u64,
pub prev_bytes_out: u64,
pub rtt_us: Option<u32>,
pub last_seen: Instant,
pub is_partial: bool,
pub is_closed: bool,
}
impl TcpState {
pub fn as_str(&self) -> &'static str;
}
From tcptop/src/output.rs:
pub fn format_bytes(bytes: u64) -> String;
pub fn format_rate(rate: f64) -> String;
pub fn format_rtt(rtt_us: Option<u32>) -> String;
From tcptop/src/aggregator.rs:
pub struct ConnectionTable { ... }
impl ConnectionTable {
pub fn new() -> Self;
pub fn seed(&mut self, records: Vec<ConnectionRecord>);
pub fn update(&mut self, event: CollectorEvent);
pub fn tick(&mut self) -> (Vec<&ConnectionRecord>, Vec<ConnectionRecord>);
pub fn connection_count(&self) -> usize;
}
-
Add to
tcptop/Cargo.tomlunder[dependencies]:ratatui = { workspace = true } crossterm = { workspace = true } futures = { workspace = true } -
Add
pub mod tui;totcptop/src/lib.rs(afterpub mod output;). -
Create
tcptop/src/tui/mod.rswith:pub mod app; pub mod draw; pub mod event; -
Create
tcptop/src/tui/app.rswith the App state struct. This is the core TUI state:InputModeenum:Normal,Filter(per D-07 state machine)SortColumnenum:Proto,LocalAddr,RemoteAddr,Pid,Process,State,RateIn,RateOut,Rtt,BytesIn,BytesOut,PacketsIn,PacketsOut(per D-01, D-04)CliFiltersstruct:port: Option<u16>,pid: Option<u32>,process: Option<String>,tcp_only: bool,udp_only: bool(for FILT-01..04, populated by Plan 02)Appstruct with fields:sort_column: SortColumn(defaultRateIn)sort_ascending: bool(defaultfalse- highest rate first)filter_text: String(empty)input_mode: InputMode(Normal)table_state: ratatui::widgets::TableState(default)show_extra_columns: bool(false, per D-04)show_help: bool(false, per D-08)selected_header_col: usize(0, for Tab navigation per D-06)new_connections: HashSet<ConnectionKey>(for D-11 green highlight)closing_connections: HashSet<ConnectionKey>(for D-11 red highlight)previous_keys: HashSet<ConnectionKey>(for detecting new connections)cli_filters: CliFilters
App::new(cli_filters: CliFilters) -> SelfconstructorApp::toggle_sort(&mut self, column: SortColumn)- if same column, flip ascending; if different column, set column and ascending=falseApp::sort_records(&self, records: &mut Vec<&ConnectionRecord>)- sort in place usingsort_columnandsort_ascending. Usepartial_cmpfor f64 fields (rate_in, rate_out),cmpfor others. Match all SortColumn variants including LocalAddr (format as string then cmp), RemoteAddr similarly.App::update_highlights(&mut self, active: &[&ConnectionRecord], closed: &[ConnectionRecord])- compute new_connections as keys in active but not in previous_keys. Set closing_connections from closed records' keys. Then set previous_keys = current active keys.App::filter_records<'a>(&self, records: &[&'a ConnectionRecord], live_filter: &str) -> Vec<&'a ConnectionRecord>- apply cli_filters (port, pid, process, tcp_only, udp_only) then live_filter string matching against addr:port and process_name. Case-insensitive contains matching. cd /Users/zrowitsch/local_src/tcptop && cargo check 2>&1 | tail -5 <acceptance_criteria>- Cargo.toml contains
ratatui = { version = "0.30.0", features = ["crossterm"] } - Cargo.toml contains
crossterm = { version = "0.29.0", features = ["event-stream"] } - Cargo.toml contains
futures = "0.3" - tcptop/Cargo.toml contains
ratatui = { workspace = true } - tcptop/Cargo.toml contains
crossterm = { workspace = true } - tcptop/Cargo.toml contains
futures = { workspace = true } - tcptop/src/lib.rs contains
pub mod tui; - tcptop/src/tui/mod.rs contains
pub mod app;andpub mod draw;andpub mod event; - tcptop/src/tui/app.rs contains
pub enum InputModewithNormalandFiltervariants - tcptop/src/tui/app.rs contains
pub enum SortColumnwithProto,LocalAddr,RemoteAddr,Pid,Process,State,RateIn,RateOut,Rttvariants - tcptop/src/tui/app.rs contains
pub struct Appwithsort_column,filter_text,input_mode,table_state,show_extra_columns,show_help,new_connections,closing_connectionsfields - tcptop/src/tui/app.rs contains
pub struct CliFilters - tcptop/src/tui/app.rs contains
pub fn toggle_sort - tcptop/src/tui/app.rs contains
pub fn sort_records - tcptop/src/tui/app.rs contains
pub fn update_highlights - tcptop/src/tui/app.rs contains
pub fn filter_records cargo checksucceeds (exit code 0) </acceptance_criteria> TUI module scaffolded with App state struct, all enums, filter/sort/highlight methods. Compiles clean.
-
pub fn draw(frame: &mut Frame, app: &mut App, connections: &[&ConnectionRecord])- main draw function. UsesLayout::verticalwith 3 chunks:Constraint::Length(4)for summary header (per D-13)Constraint::Min(5)for connection table (DISP-01)Constraint::Length(1)for status bar (per D-14) Callsdraw_header,draw_table,draw_status_bar. Ifapp.show_help, callsdraw_help_overlayon top.
-
fn draw_header(frame: &mut Frame, area: Rect, connections: &[&ConnectionRecord])- renders aParagraphwidget (per D-13). Content:- Line 1:
"tcptop — {total} connections ({tcp_count} TCP, {udp_count} UDP)"- count by protocol - Line 2:
"Bandwidth: In: {total_rate_in} Out: {total_rate_out}"- sum of all rate_in/rate_out formatted viacrate::output::format_rate() - Line 3: horizontal separator using
"─".repeat(area.width)UseBlock::default()with no borders.
- Line 1:
-
fn draw_table(frame: &mut Frame, area: Rect, app: &mut App, connections: &[&ConnectionRecord])- builds a ratatuiTablewidget:- Header row (per D-01):
["Proto", "Local Addr:Port", "Remote Addr:Port", "PID", "Process", "State", "Rate In", "Rate Out", "RTT"]. Ifapp.show_extra_columns(D-04), append["Bytes In", "Bytes Out", "Pkts In", "Pkts Out"]. - Header style:
Style::default().add_modifier(Modifier::BOLD). The column matchingapp.sort_columngets underline modifier and a direction indicator arrow appended: " ↑" if ascending, " ↓" if descending. - For Tab navigation (D-06): the column at index
app.selected_header_colgetsbg(Color::DarkGray)to show selection. - Data rows: for each ConnectionRecord, create a
Rowwith cells:- Proto:
"TCP"or"UDP"fromrecord.key.protocol - Local:
format!("{}:{}", record.key.local_addr, record.key.local_port)(per D-02) - Remote:
format!("{}:{}", record.key.remote_addr, record.key.remote_port)(per D-02) - PID:
record.pid.to_string()(per D-03) - Process:
record.process_name.clone()+"*"suffix ifrecord.is_partial(per D-12). Truncate with ellipsis if needed (D-05, ratatui handles this via Constraint but also add..manually if >15 chars for safety). - State:
record.tcp_state.map(|s| s.as_str()).unwrap_or("UDP")(per D-07 in Phase 1) - Rate In:
crate::output::format_rate(record.rate_in) - Rate Out:
crate::output::format_rate(record.rate_out) - RTT:
crate::output::format_rtt(record.rtt_us) - If extra columns:
crate::output::format_bytes(record.bytes_in),format_bytes(record.bytes_out),record.packets_in.to_string(),record.packets_out.to_string()
- Proto:
- Row styling (per D-10 and D-11):
- If key is in
app.new_connections:Style::default().bg(Color::DarkGreen)(per D-11) - Else if key is in
app.closing_connections:Style::default().bg(Color::DarkRed)(per D-11) - Else:
bandwidth_style(record.rate_in + record.rate_out)(per D-10)
- If key is in
- Column widths:
[Length(5), Min(15), Min(15), Length(7), Length(15), Length(12), Length(10), Length(10), Length(8)]. If extra columns, append[Length(12), Length(12), Length(8), Length(8)]. - Render with
frame.render_stateful_widget(table, area, &mut app.table_state)for scroll support. - Row highlight style:
Style::default().add_modifier(Modifier::REVERSED)for selected row.
- Header row (per D-01):
-
fn draw_status_bar(frame: &mut Frame, area: Rect, app: &App)- renders a single-lineParagraph(per D-14, D-15):- Normal mode:
"Sort: {column_name} {arrow} | /:filter ?:help c:columns q:quit" - Filter mode:
"Filter: {app.filter_text}_"(underscore as cursor indicator) - Style:
bg(Color::DarkGray),fg(Color::White)
- Normal mode:
-
fn draw_help_overlay(frame: &mut Frame, area: Rect)- centered popup (per D-08):- Calculate centered rect: 60 wide, 20 tall (or 60%/60% of terminal, whichever smaller)
Block::bordered()with title" Help "- Content: list of keybindings:
q / Ctrl-C Quit / Filter (type to search, Esc to clear) ? Toggle this help c Toggle extra columns (bytes, packets) r / R Sort by Rate In / Rate Out p / n Sort by PID / Process Name s / t Sort by State / RTT a / A Sort by Local Addr / Remote Addr P Sort by Protocol Tab / Enter Navigate/select column header j/k or ↑/↓ Scroll rows * next to process name = pre-existing connection (partial data) - Clear the area behind the popup before rendering.
-
fn bandwidth_style(total_rate: f64) -> Style(per D-10):< 1024.0(< 1 KB/s):fg(Color::DarkGray)(dim)< 102400.0(< 100 KB/s):fg(Color::White)(normal)< 1048576.0(< 1 MB/s):fg(Color::White).add_modifier(Modifier::BOLD)(bright)>= 1048576.0(>= 1 MB/s):fg(Color::Yellow).add_modifier(Modifier::BOLD)(very bright)
-
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect- helper to compute centered rectangle for overlay.
-
Create
tcptop/src/tui/event.rswith keyboard event handling:-
pub enum Action { Continue, Quit }- return type from event handler -
pub fn handle_event(app: &mut App, event: crossterm::event::Event) -> Action- process keyboard events:- Filter to
Event::Key(key)wherekey.kind == KeyEventKind::Pressonly (avoid double events per Pitfall 3 in research) - Check for Ctrl-C first (any mode):
key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)-> returnAction::Quit - Match on
app.input_mode:InputMode::Normal:KeyCode::Char('q')->Action::QuitKeyCode::Char('/')-> setapp.input_mode = InputMode::FilterKeyCode::Char('?')-> toggleapp.show_helpKeyCode::Char('c')-> toggleapp.show_extra_columns(per D-04)KeyCode::Char('r')->app.toggle_sort(SortColumn::RateIn)(per D-06)KeyCode::Char('R')->app.toggle_sort(SortColumn::RateOut)KeyCode::Char('p')->app.toggle_sort(SortColumn::Pid)KeyCode::Char('n')->app.toggle_sort(SortColumn::Process)KeyCode::Char('s')->app.toggle_sort(SortColumn::State)KeyCode::Char('t')->app.toggle_sort(SortColumn::Rtt)KeyCode::Char('a')->app.toggle_sort(SortColumn::LocalAddr)KeyCode::Char('A')->app.toggle_sort(SortColumn::RemoteAddr)KeyCode::Char('P')->app.toggle_sort(SortColumn::Proto)KeyCode::Tab-> advanceapp.selected_header_col(mod number of visible columns)KeyCode::Enter-> sort by column atapp.selected_header_colusing a mapping arrayKeyCode::Char('j') | KeyCode::Down->app.table_state.select_next()KeyCode::Char('k') | KeyCode::Up->app.table_state.select_previous()- _ -> do nothing
InputMode::Filter(per D-07):KeyCode::Esc-> clearapp.filter_text, setapp.input_mode = InputMode::Normal, reset table_state selection to 0KeyCode::Enter-> setapp.input_mode = InputMode::Normal(keep filter text)KeyCode::Char(c)-> pushctoapp.filter_textKeyCode::Backspace-> pop fromapp.filter_text- _ -> do nothing
- Return
Action::Continuefor all non-quit cases. cd /Users/zrowitsch/local_src/tcptop && cargo check 2>&1 | tail -5 <acceptance_criteria>
- Filter to
-
tcptop/src/tui/draw.rs contains
pub fn draw(frame: &mut Frame, app: &mut App, connections: &[&ConnectionRecord]) -
tcptop/src/tui/draw.rs contains
fn draw_header -
tcptop/src/tui/draw.rs contains
fn draw_table -
tcptop/src/tui/draw.rs contains
fn draw_status_bar -
tcptop/src/tui/draw.rs contains
fn draw_help_overlay -
tcptop/src/tui/draw.rs contains
fn bandwidth_style -
tcptop/src/tui/draw.rs contains
Layout::verticalorLayout::new(Direction::Vertical -
tcptop/src/tui/draw.rs contains
format_rate(reuse from output.rs) -
tcptop/src/tui/draw.rs contains
format_rtt(reuse from output.rs) -
tcptop/src/tui/draw.rs contains
Color::DarkGreen(D-11 new connection highlight) -
tcptop/src/tui/draw.rs contains
Color::DarkRed(D-11 closing connection highlight) -
tcptop/src/tui/event.rs contains
pub enum ActionwithContinueandQuit -
tcptop/src/tui/event.rs contains
pub fn handle_event -
tcptop/src/tui/event.rs contains
KeyEventKind::Press(filter for press-only events) -
tcptop/src/tui/event.rs contains
KeyModifiers::CONTROL(Ctrl-C handling) -
tcptop/src/tui/event.rs contains
InputMode::Filter(filter state machine) -
cargo checksucceeds (exit code 0) </acceptance_criteria> draw.rs renders header (3-4 lines with connection counts and bandwidth per D-13), connection table (9 default columns per D-01, extra 4 via toggle per D-04), status bar (sort/filter info per D-14), and help overlay (per D-08). event.rs handles all keyboard shortcuts including mnemonic sort keys (per D-06), filter-as-you-type (per D-07), quit (per D-08), column toggle, help toggle, and row scrolling. Bandwidth coloring applied per D-10. Connection highlights for new/closing per D-11. Partial marker asterisk per D-12.
-
#[derive(Parser, Debug)] #[command(name = "tcptop", about = "Real-time per-connection network monitor")] struct Cli { /// Filter by port (matches source or destination) #[arg(long)] port: Option,
/// Filter by process ID
#[arg(long)]
pid: Option<u32>,
/// Filter by process name (substring match)
#[arg(long)]
process: Option<String>,
/// Network interface to monitor
#[arg(long, short = 'i')]
interface: Option<String>,
/// Show only TCP connections
#[arg(long)]
tcp: bool,
/// Show only UDP connections
#[arg(long)]
udp: bool,
/// Refresh interval in seconds (default: 1)
#[arg(long, default_value = "1")]
interval: u64,
}
2. Update `main()` to parse CLI args and init terminal:
```rust
#[tokio::main]
async fn main() -> anyhow::Result<()> {
env_logger::init();
tcptop::privilege::check_privileges();
let cli = Cli::parse();
#[cfg(target_os = "linux")]
{
// Initialize terminal BEFORE entering async code
let mut terminal = ratatui::init();
let result = run_linux(&mut terminal, &cli).await;
ratatui::restore();
result?;
}
#[cfg(not(target_os = "linux"))]
{
eprintln!("tcptop: eBPF collector only supported on Linux. macOS backend planned for Phase 4.");
std::process::exit(1);
}
#[allow(unreachable_code)]
Ok(())
}
-
Rewrite
run_linux()to accept terminal and CLI args:#[cfg(target_os = "linux")] async fn run_linux(terminal: &mut ratatui::DefaultTerminal, cli: &Cli) -> anyhow::Result<()> { // Same collector/table setup as before let mut collector = LinuxCollector::new()?; let mut table = ConnectionTable::new(); // Bootstrap match collector.bootstrap_existing() { /* same as before */ } // Channel + spawn collector (same as before) let (tx, mut rx) = mpsc::channel(4096); let collector_handle = tokio::spawn(async move { if let Err(e) = collector.start(tx).await { log::error!("Collector error: {}", e); } }); // Create App state with CLI filters let cli_filters = tcptop::tui::app::CliFilters { port: cli.port, pid: cli.pid, process: cli.process.clone(), tcp_only: cli.tcp, udp_only: cli.udp, }; let mut app = tcptop::tui::app::App::new(cli_filters); // Use CLI-specified interval (DISP-05) let mut tick = interval(Duration::from_secs(cli.interval)); // Async keyboard input stream let mut event_stream = crossterm::event::EventStream::new(); // Signal handlers (same as before) let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; loop { tokio::select! { Some(event) = rx.recv() => { table.update(event); } _ = tick.tick() => { let (active, closed) = table.tick(); // Update new/closing connection highlights (D-11) app.update_highlights(&active, &closed); // Filter (CLI + live filter) then sort let mut filtered = app.filter_records(&active, &app.filter_text.clone()); app.sort_records(&mut filtered); // Clamp selection to filtered length (Pitfall 4) if let Some(sel) = app.table_state.selected() { if sel >= filtered.len() { app.table_state.select(Some(filtered.len().saturating_sub(1))); } } // Render terminal.draw(|frame| { tcptop::tui::draw::draw(frame, &mut app, &filtered); })?; } Some(Ok(evt)) = event_stream.next() => { if let tcptop::tui::event::Action::Quit = tcptop::tui::event::handle_event(&mut app, evt) { break; } } _ = sigint.recv() => break, _ = sigterm.recv() => break, } } collector_handle.abort(); Ok(()) } -
Add required imports at the top of main.rs:
use futures::StreamExt; // for event_stream.next() use clap::Parser; -
Remove the
tcptop::output::print_header()andtcptop::output::print_tick()calls -- they are replaced by TUI rendering.
NOTE: The --interface flag is parsed but not yet passed to the collector (FILT-03). The LinuxCollector::new() takes no args currently. Plan 02 Task 3 will wire interface filtering. For now the flag is accepted by clap but ignored with a log::warn if provided.
cd /Users/zrowitsch/local_src/tcptop && cargo check 2>&1 | tail -5
<acceptance_criteria>
- tcptop/src/main.rs contains #[derive(Parser (clap derive struct)
- tcptop/src/main.rs contains --port or port: Option<u16> (FILT-01 flag)
- tcptop/src/main.rs contains --pid or pid: Option<u32> (FILT-02 flag)
- tcptop/src/main.rs contains --process or process: Option<String> (FILT-02 flag)
- tcptop/src/main.rs contains --interface or interface: Option<String> (FILT-03 flag)
- tcptop/src/main.rs contains --tcp or tcp: bool (FILT-04 flag)
- tcptop/src/main.rs contains --udp or udp: bool (FILT-04 flag)
- tcptop/src/main.rs contains --interval or interval: u64 (DISP-05 flag)
- tcptop/src/main.rs contains ratatui::init() (terminal initialization)
- tcptop/src/main.rs contains ratatui::restore() (terminal restoration)
- tcptop/src/main.rs contains EventStream::new() or event_stream (async keyboard input)
- tcptop/src/main.rs contains event_stream.next() (keyboard events in select loop)
- tcptop/src/main.rs contains handle_event (keyboard event processing)
- tcptop/src/main.rs contains terminal.draw (TUI rendering)
- tcptop/src/main.rs does NOT contain print_header() (removed)
- tcptop/src/main.rs does NOT contain print_tick( (removed, replaced by TUI draw)
- cargo check succeeds (exit code 0)
</acceptance_criteria>
main.rs wired to TUI: terminal init/restore around event loop, clap Cli struct with all flags (--port, --pid, --process, --interface, --tcp, --udp, --interval), EventStream in tokio::select! for non-blocking keyboard input, render on tick with filter+sort+draw pipeline. Phase 1 stdout output replaced with ratatui rendering.
<success_criteria>
- Running
cargo checksucceeds - TUI module exists with App state, draw functions, event handler
- main.rs replaces stdout output with TUI rendering
- All 9 default columns rendered in table (Proto, Local, Remote, PID, Process, State, Rate In, Rate Out, RTT)
- Summary header shows connection counts and aggregate bandwidth
- Status bar shows sort column and direction
- Bandwidth color coding applied (dim to bright per D-10)
- New/closing connection highlights implemented (D-11)
- Quit works via 'q' or Ctrl-C (DISP-08)
- --interval flag configures refresh rate (DISP-05) </success_criteria>