// TUI dashboard for poc-memory daemon // // Connects to the daemon status socket, polls periodically, and renders // a tabbed interface with per-agent-type tabs for drill-down. Designed // for observability and control of the consolidation system. // // Tabs: // Overview — graph health gauges, in-flight tasks, recent completions // Pipeline — daily pipeline phases in execution order // — one tab per agent type (replay, linker, separator, transfer, // health, apply, etc.) showing all runs with output + log history // Log — auto-scrolling daemon.log tail use crate::agents::daemon::GraphHealth; use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use jobkit::{TaskInfo, TaskStatus}; use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Tabs, Wrap}, DefaultTerminal, Frame, }; use std::fs; use std::io::Read as _; use std::os::unix::net::UnixStream; use std::path::PathBuf; use std::time::{Duration, Instant}; const POLL_INTERVAL: Duration = Duration::from_secs(2); // Agent types we know about, in display order const AGENT_TYPES: &[&str] = &[ "health", "replay", "linker", "separator", "transfer", "apply", "orphans", "cap", "digest", "digest-links", "knowledge", "rename", "split", ]; fn status_sock_path() -> PathBuf { crate::config::get().data_dir.join("daemon.sock") } fn log_path() -> PathBuf { crate::config::get().data_dir.join("daemon.log") } // --- Data fetching --- #[derive(serde::Deserialize)] struct DaemonStatus { #[allow(dead_code)] pid: u32, tasks: Vec, #[serde(default)] #[allow(dead_code)] last_daily: Option, #[serde(default)] graph_health: Option, } fn fetch_status() -> Option { let mut stream = UnixStream::connect(status_sock_path()).ok()?; stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); let mut buf = String::new(); stream.read_to_string(&mut buf).ok()?; serde_json::from_str(&buf).ok() } #[derive(Clone)] struct LogEntry { ts: String, job: String, event: String, detail: String, } fn load_log_entries(max: usize) -> Vec { let content = match fs::read_to_string(log_path()) { Ok(c) => c, Err(_) => return Vec::new(), }; content .lines() .rev() .take(max) .filter_map(|line| { let obj: serde_json::Value = serde_json::from_str(line).ok()?; Some(LogEntry { ts: obj.get("ts")?.as_str()?.to_string(), job: obj.get("job")?.as_str()?.to_string(), event: obj.get("event")?.as_str()?.to_string(), detail: obj .get("detail") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), }) }) .collect::>() .into_iter() .rev() .collect() } // --- Tab model --- #[derive(Clone, PartialEq, Eq)] enum Tab { Overview, Pipeline, Agent(String), // agent type name: "replay", "linker", etc. Log, } impl Tab { fn label(&self) -> String { match self { Tab::Overview => "Overview".into(), Tab::Pipeline => "Pipeline".into(), Tab::Agent(name) => name.clone(), Tab::Log => "Log".into(), } } } // --- App state --- struct App { tabs: Vec, tab_idx: usize, status: Option, log_entries: Vec, last_poll: Instant, scroll: usize, count_prefix: Option, // numeric prefix for commands (vim-style) flash_msg: Option<(String, Instant)>, // transient status message } impl App { fn new() -> Self { let status = fetch_status(); let log_entries = load_log_entries(500); let tabs = Self::build_tabs(&status, &log_entries); Self { tabs, tab_idx: 0, status, log_entries, last_poll: Instant::now(), scroll: 0, count_prefix: None, flash_msg: None, } } fn build_tabs(status: &Option, log_entries: &[LogEntry]) -> Vec { let mut tabs = vec![Tab::Overview, Tab::Pipeline]; for agent_type in AGENT_TYPES { let prefix = format!("c-{}", agent_type); let has_tasks = status .as_ref() .map(|s| s.tasks.iter().any(|t| t.name.starts_with(&prefix))) .unwrap_or(false); let has_logs = log_entries.iter().any(|e| { e.job.starts_with(&prefix) || e.job == *agent_type }); if has_tasks || has_logs { tabs.push(Tab::Agent(agent_type.to_string())); } } tabs.push(Tab::Log); tabs } fn poll(&mut self) { if self.last_poll.elapsed() >= POLL_INTERVAL { self.status = fetch_status(); self.log_entries = load_log_entries(500); // Rebuild tabs, preserving current selection let current = self.tabs.get(self.tab_idx).cloned(); self.tabs = Self::build_tabs(&self.status, &self.log_entries); if let Some(ref cur) = current { self.tab_idx = self.tabs.iter().position(|t| t == cur).unwrap_or(0); } self.last_poll = Instant::now(); } } fn current_tab(&self) -> &Tab { self.tabs.get(self.tab_idx).unwrap_or(&Tab::Overview) } fn tasks(&self) -> &[TaskInfo] { self.status .as_ref() .map(|s| s.tasks.as_slice()) .unwrap_or(&[]) } fn tasks_for_agent(&self, agent_type: &str) -> Vec<&TaskInfo> { let prefix = format!("c-{}", agent_type); self.tasks() .iter() .filter(|t| t.name.starts_with(&prefix)) .collect() } fn logs_for_agent(&self, agent_type: &str) -> Vec<&LogEntry> { let prefix = format!("c-{}", agent_type); self.log_entries .iter() .filter(|e| e.job.starts_with(&prefix) || e.job == agent_type) .collect() } fn pipeline_tasks(&self) -> Vec<&TaskInfo> { self.tasks() .iter() .filter(|t| { let n = &t.name; n.starts_with("c-") || n.starts_with("consolidate:") || n.starts_with("knowledge-loop:") || n.starts_with("digest:") || n.starts_with("decay:") }) .collect() } fn next_tab(&mut self) { self.tab_idx = (self.tab_idx + 1) % self.tabs.len(); self.scroll = 0; } fn prev_tab(&mut self) { self.tab_idx = (self.tab_idx + self.tabs.len() - 1) % self.tabs.len(); self.scroll = 0; } } // --- Rendering --- fn format_duration(d: Duration) -> String { let ms = d.as_millis(); if ms < 1_000 { format!("{}ms", ms) } else if ms < 60_000 { format!("{:.1}s", ms as f64 / 1000.0) } else if ms < 3_600_000 { format!("{}m{}s", ms / 60_000, (ms % 60_000) / 1000) } else { format!("{}h{}m", ms / 3_600_000, (ms % 3_600_000) / 60_000) } } fn task_elapsed(t: &TaskInfo) -> Duration { if matches!(t.status, TaskStatus::Running) { if let Some(started) = t.started_at { let now = std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_secs_f64(); Duration::from_secs_f64((now - started).max(0.0)) } else { t.elapsed } } else { t.result.as_ref().map(|r| r.duration).unwrap_or(t.elapsed) } } fn status_style(t: &TaskInfo) -> Style { if t.cancelled { return Style::default().fg(Color::DarkGray); } match t.status { TaskStatus::Running => Style::default().fg(Color::Green), TaskStatus::Completed => Style::default().fg(Color::Blue), TaskStatus::Failed => Style::default().fg(Color::Red), TaskStatus::Pending => Style::default().fg(Color::DarkGray), } } fn status_symbol(t: &TaskInfo) -> &'static str { if t.cancelled { return "✗"; } match t.status { TaskStatus::Running => "▶", TaskStatus::Completed => "✓", TaskStatus::Failed => "✗", TaskStatus::Pending => "·", } } fn event_style(event: &str) -> Style { match event { "completed" => Style::default().fg(Color::Blue), "failed" => Style::default().fg(Color::Red), "started" => Style::default().fg(Color::Green), _ => Style::default().fg(Color::DarkGray), } } fn event_symbol(event: &str) -> &'static str { match event { "completed" => "✓", "failed" => "✗", "started" => "▶", _ => "·", } } fn ts_time(ts: &str) -> &str { if ts.len() >= 19 { &ts[11..19] } else { ts } } fn render(frame: &mut Frame, app: &App) { let [header, body, footer] = Layout::vertical([ Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ]) .areas(frame.area()); // Tab bar — show index hints for first 9 tabs let tab_titles: Vec = app .tabs .iter() .enumerate() .map(|(i, t)| { let hint = if i < 9 { format!("{}", i + 1) } else { " ".into() }; Line::from(format!(" {} {} ", hint, t.label())) }) .collect(); let tabs = Tabs::new(tab_titles) .select(app.tab_idx) .highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ) .block(Block::default().borders(Borders::ALL).title(" poc-memory daemon ")); frame.render_widget(tabs, header); // Body match app.current_tab() { Tab::Overview => render_overview(frame, app, body), Tab::Pipeline => render_pipeline(frame, app, body), Tab::Agent(name) => render_agent_tab(frame, app, name, body), Tab::Log => render_log(frame, app, body), } // Footer — flash message, count prefix, or help text let footer_text = if let Some((ref msg, when)) = app.flash_msg { if when.elapsed() < Duration::from_secs(3) { Line::from(vec![ Span::raw(" "), Span::styled(msg.as_str(), Style::default().fg(Color::Green)), ]) } else { Line::raw("") // expired, will show help below } } else { Line::raw("") }; let footer_line = if !footer_text.spans.is_empty() { footer_text } else if let Some(n) = app.count_prefix { Line::from(vec![ Span::styled(format!(" {}×", n), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), Span::raw(" r: run agent │ Esc: cancel"), ]) } else { match app.current_tab() { Tab::Agent(_) => Line::from( " Tab: switch │ ↑↓: scroll │ [N]r: run agent │ c: consolidate │ q: quit ", ), _ => Line::from( " Tab/1-9: switch │ ↑↓: scroll │ c: consolidate │ q: quit ", ), } }; let footer_widget = Paragraph::new(footer_line).style(Style::default().fg(Color::DarkGray)); frame.render_widget(footer_widget, footer); } // --- Overview tab --- fn render_overview(frame: &mut Frame, app: &App, area: Rect) { let [health_area, tasks_area] = Layout::vertical([Constraint::Length(12), Constraint::Min(0)]).areas(area); if let Some(ref gh) = app.status.as_ref().and_then(|s| s.graph_health.as_ref()) { render_health(frame, gh, health_area); } else { let p = Paragraph::new(" No graph health data available") .block(Block::default().borders(Borders::ALL).title(" Graph Health ")); frame.render_widget(p, health_area); } // In-flight + recent let in_flight: Vec<&TaskInfo> = app .tasks() .iter() .filter(|t| matches!(t.status, TaskStatus::Running | TaskStatus::Pending)) .collect(); let mut lines: Vec = Vec::new(); if in_flight.is_empty() { lines.push(Line::from(" No tasks in flight").fg(Color::DarkGray)); } else { for t in &in_flight { let elapsed = task_elapsed(t); let progress = t .progress .as_deref() .filter(|p| *p != "idle") .unwrap_or(""); lines.push(Line::from(vec![ Span::styled(format!(" {} ", status_symbol(t)), status_style(t)), Span::raw(format!("{:30}", short_name(&t.name))), Span::styled( format!(" {:>8}", format_duration(elapsed)), Style::default().fg(Color::DarkGray), ), Span::raw(format!(" {}", progress)), ])); if matches!(t.status, TaskStatus::Running) && !t.output_log.is_empty() { let skip = t.output_log.len().saturating_sub(2); for line in &t.output_log[skip..] { lines.push(Line::from(format!(" │ {}", line)).fg(Color::DarkGray)); } } } } lines.push(Line::raw("")); lines.push(Line::from(" Recent:").fg(Color::DarkGray)); let recent: Vec<&LogEntry> = app .log_entries .iter() .rev() .filter(|e| e.event == "completed" || e.event == "failed") .take(10) .collect::>() .into_iter() .rev() .collect(); for entry in &recent { lines.push(Line::from(vec![ Span::raw(" "), Span::styled(event_symbol(&entry.event), event_style(&entry.event)), Span::raw(format!( " {} {:28} {}", ts_time(&entry.ts), short_name(&entry.job), entry.detail )), ])); } let tasks_widget = Paragraph::new(lines) .block(Block::default().borders(Borders::ALL).title(" Tasks ")) .scroll((app.scroll as u16, 0)); frame.render_widget(tasks_widget, tasks_area); } fn render_health(frame: &mut Frame, gh: &GraphHealth, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(format!(" Graph Health ({}) ", gh.computed_at)); let inner = block.inner(area); frame.render_widget(block, area); let [metrics_area, gauges_area, plan_area] = Layout::vertical([ Constraint::Length(2), Constraint::Length(4), Constraint::Min(1), ]) .areas(inner); // Metrics let summary = Line::from(format!( " {} nodes {} edges {} communities", gh.nodes, gh.edges, gh.communities )); let ep_line = Line::from(vec![ Span::raw(" episodic: "), Span::styled( format!("{:.0}%", gh.episodic_ratio * 100.0), if gh.episodic_ratio < 0.4 { Style::default().fg(Color::Green) } else { Style::default().fg(Color::Red) }, ), Span::raw(format!(" σ={:.1}", gh.sigma)), ]); frame.render_widget(Paragraph::new(vec![summary, ep_line]), metrics_area); // Gauges let [g1, g2, g3] = Layout::horizontal([ Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), ]) .areas(gauges_area); let alpha_color = if gh.alpha >= 2.5 { Color::Green } else { Color::Red }; frame.render_widget( Gauge::default() .block(Block::default().borders(Borders::ALL).title(" α (≥2.5) ")) .gauge_style(Style::default().fg(alpha_color)) .ratio((gh.alpha / 5.0).clamp(0.0, 1.0) as f64) .label(format!("{:.2}", gh.alpha)), g1, ); let gini_color = if gh.gini <= 0.4 { Color::Green } else { Color::Red }; frame.render_widget( Gauge::default() .block(Block::default().borders(Borders::ALL).title(" gini (≤0.4) ")) .gauge_style(Style::default().fg(gini_color)) .ratio(gh.gini.clamp(0.0, 1.0) as f64) .label(format!("{:.3}", gh.gini)), g2, ); let cc_color = if gh.avg_cc >= 0.2 { Color::Green } else { Color::Red }; frame.render_widget( Gauge::default() .block(Block::default().borders(Borders::ALL).title(" cc (≥0.2) ")) .gauge_style(Style::default().fg(cc_color)) .ratio(gh.avg_cc.clamp(0.0, 1.0) as f64) .label(format!("{:.3}", gh.avg_cc)), g3, ); // Plan let total = gh.plan_replay + gh.plan_linker + gh.plan_separator + gh.plan_transfer + 1; let plan_line = Line::from(vec![ Span::raw(" plan: "), Span::styled( format!("{}", total), Style::default().add_modifier(Modifier::BOLD), ), Span::raw(format!( " agents ({}r {}l {}s {}t +health)", gh.plan_replay, gh.plan_linker, gh.plan_separator, gh.plan_transfer )), ]); frame.render_widget(Paragraph::new(plan_line), plan_area); } // --- Pipeline tab --- fn render_pipeline(frame: &mut Frame, app: &App, area: Rect) { let pipeline = app.pipeline_tasks(); if pipeline.is_empty() { let p = Paragraph::new(" No pipeline tasks") .block(Block::default().borders(Borders::ALL).title(" Daily Pipeline ")); frame.render_widget(p, area); return; } let phase_order = [ "c-health", "c-replay", "c-linker", "c-separator", "c-transfer", "c-apply", "c-orphans", "c-cap", "c-digest", "c-digest-links", "c-knowledge", ]; let mut rows: Vec = Vec::new(); let mut seen = std::collections::HashSet::new(); for phase in &phase_order { for t in &pipeline { if t.name.starts_with(phase) && seen.insert(&t.name) { rows.push(pipeline_row(t)); } } } for t in &pipeline { if seen.insert(&t.name) { rows.push(pipeline_row(t)); } } let header = Row::new(vec!["", "Phase", "Status", "Duration", "Progress"]) .style( Style::default() .add_modifier(Modifier::BOLD) .fg(Color::DarkGray), ); let widths = [ Constraint::Length(2), Constraint::Length(30), Constraint::Length(10), Constraint::Length(10), Constraint::Min(20), ]; let table = Table::new(rows, widths) .header(header) .block(Block::default().borders(Borders::ALL).title(" Daily Pipeline ")); frame.render_widget(table, area); } fn pipeline_row(t: &TaskInfo) -> Row<'static> { let elapsed = task_elapsed(t); let progress = t.progress.as_deref().unwrap_or("").to_string(); let error = t .result .as_ref() .and_then(|r| r.error.as_ref()) .map(|e| { let short = if e.len() > 40 { &e[..40] } else { e }; format!("err: {}", short) }) .unwrap_or_default(); let detail = if !error.is_empty() { error } else { progress }; Row::new(vec![ Cell::from(status_symbol(t)).style(status_style(t)), Cell::from(short_name(&t.name)), Cell::from(format!("{}", t.status)), Cell::from(if !elapsed.is_zero() { format_duration(elapsed) } else { String::new() }), Cell::from(detail), ]) .style(status_style(t)) } // --- Per-agent-type tab --- fn render_agent_tab(frame: &mut Frame, app: &App, agent_type: &str, area: Rect) { let tasks = app.tasks_for_agent(agent_type); let logs = app.logs_for_agent(agent_type); let mut lines: Vec = Vec::new(); // Active/recent tasks if tasks.is_empty() { lines.push(Line::from(" No active tasks").fg(Color::DarkGray)); } else { lines.push(Line::styled( " Tasks:", Style::default().add_modifier(Modifier::BOLD), )); lines.push(Line::raw("")); for t in &tasks { let elapsed = task_elapsed(t); let elapsed_str = if !elapsed.is_zero() { format_duration(elapsed) } else { String::new() }; let progress = t .progress .as_deref() .filter(|p| *p != "idle") .unwrap_or(""); lines.push(Line::from(vec![ Span::styled(format!(" {} ", status_symbol(t)), status_style(t)), Span::styled(format!("{:30}", &t.name), status_style(t)), Span::styled( format!(" {:>8}", elapsed_str), Style::default().fg(Color::DarkGray), ), Span::raw(format!(" {}", progress)), ])); // Retries if t.max_retries > 0 && t.retry_count > 0 { lines.push(Line::from(vec![ Span::raw(" retry "), Span::styled( format!("{}/{}", t.retry_count, t.max_retries), Style::default().fg(Color::Yellow), ), ])); } // Output log if !t.output_log.is_empty() { for log_line in &t.output_log { lines.push(Line::from(format!(" │ {}", log_line)).fg(Color::DarkGray)); } } // Error if matches!(t.status, TaskStatus::Failed) { if let Some(ref r) = t.result { if let Some(ref err) = r.error { lines.push(Line::from(vec![ Span::styled(" error: ", Style::default().fg(Color::Red)), Span::styled(err.as_str(), Style::default().fg(Color::Red)), ])); } } } lines.push(Line::raw("")); } } // Log history for this agent type lines.push(Line::styled( " Log history:", Style::default().add_modifier(Modifier::BOLD), )); lines.push(Line::raw("")); if logs.is_empty() { lines.push(Line::from(" (no log entries)").fg(Color::DarkGray)); } else { // Show last 30 entries let start = logs.len().saturating_sub(30); for entry in &logs[start..] { lines.push(Line::from(vec![ Span::raw(" "), Span::styled(event_symbol(&entry.event), event_style(&entry.event)), Span::raw(" "), Span::styled(ts_time(&entry.ts), Style::default().fg(Color::DarkGray)), Span::raw(" "), Span::styled(format!("{:12}", entry.event), event_style(&entry.event)), Span::raw(format!(" {}", entry.detail)), ])); } } let title = format!(" {} ", agent_type); let p = Paragraph::new(lines) .block(Block::default().borders(Borders::ALL).title(title)) .wrap(Wrap { trim: false }) .scroll((app.scroll as u16, 0)); frame.render_widget(p, area); } // --- Log tab --- fn render_log(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default().borders(Borders::ALL).title(" Daemon Log "); let inner = block.inner(area); frame.render_widget(block, area); let visible_height = inner.height as usize; let total = app.log_entries.len(); // Auto-scroll to bottom unless user has scrolled up let offset = if app.scroll == 0 { total.saturating_sub(visible_height) } else { app.scroll.min(total.saturating_sub(visible_height)) }; let mut lines: Vec = Vec::new(); for entry in app.log_entries.iter().skip(offset).take(visible_height) { lines.push(Line::from(vec![ Span::styled(ts_time(&entry.ts), Style::default().fg(Color::DarkGray)), Span::raw(" "), Span::styled(format!("{:12}", entry.event), event_style(&entry.event)), Span::raw(format!(" {:30} {}", short_name(&entry.job), entry.detail)), ])); } frame.render_widget(Paragraph::new(lines), inner); } // --- Helpers --- fn short_name(name: &str) -> String { if let Some((verb, path)) = name.split_once(' ') { let file = path.rsplit('/').next().unwrap_or(path); let file = file.strip_suffix(".jsonl").unwrap_or(file); let short = if file.len() > 12 { &file[..12] } else { file }; format!("{} {}", verb, short) } else { name.to_string() } } fn send_rpc(cmd: &str) -> Option { let mut stream = UnixStream::connect(status_sock_path()).ok()?; stream.set_write_timeout(Some(Duration::from_secs(2))).ok(); stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); std::io::Write::write_all(&mut stream, cmd.as_bytes()).ok()?; stream.shutdown(std::net::Shutdown::Write).ok()?; let mut buf = String::new(); stream.read_to_string(&mut buf).ok()?; Some(buf) } // --- Entry point --- pub fn run_tui() -> Result<(), String> { use crossterm::terminal; terminal::enable_raw_mode().map_err(|e| format!("not a terminal: {}", e))?; terminal::disable_raw_mode().ok(); let mut terminal = ratatui::init(); let result = run_event_loop(&mut terminal); ratatui::restore(); result } fn run_event_loop(terminal: &mut DefaultTerminal) -> Result<(), String> { let mut app = App::new(); if app.status.is_none() { return Err("Daemon not running.".into()); } loop { terminal .draw(|frame| render(frame, &app)) .map_err(|e| format!("draw: {}", e))?; if event::poll(Duration::from_millis(250)).map_err(|e| format!("poll: {}", e))? { if let Event::Key(key) = event::read().map_err(|e| format!("read: {}", e))? { match key.code { KeyCode::Char('q') => return Ok(()), KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { return Ok(()) } KeyCode::Char('c') => { let _ = send_rpc("consolidate"); app.last_poll = Instant::now() - POLL_INTERVAL; } KeyCode::Char('r') => { // Run specific agent type if on an agent tab if let Tab::Agent(ref name) = app.current_tab().clone() { let count = app.count_prefix.unwrap_or(1); let cmd = format!("run-agent {} {}", name, count); let _ = send_rpc(&cmd); app.flash_msg = Some(( format!("Queued {} {} run{}", count, name, if count > 1 { "s" } else { "" }), Instant::now(), )); app.count_prefix = None; app.last_poll = Instant::now() - POLL_INTERVAL; } } KeyCode::Tab => { app.count_prefix = None; app.next_tab(); } KeyCode::BackTab => { app.count_prefix = None; app.prev_tab(); } // Number keys: if on agent tab, accumulate as count prefix; // otherwise switch tabs KeyCode::Char(c @ '1'..='9') => { if matches!(app.current_tab(), Tab::Agent(_)) { let digit = (c as usize) - ('0' as usize); app.count_prefix = Some( app.count_prefix.unwrap_or(0) * 10 + digit ); } else { let idx = (c as usize) - ('1' as usize); if idx < app.tabs.len() { app.tab_idx = idx; app.scroll = 0; } } } KeyCode::Down | KeyCode::Char('j') => { app.scroll = app.scroll.saturating_add(1); } KeyCode::Up | KeyCode::Char('k') => { app.scroll = app.scroll.saturating_sub(1); } KeyCode::PageDown => { app.scroll = app.scroll.saturating_add(20); } KeyCode::PageUp => { app.scroll = app.scroll.saturating_sub(20); } KeyCode::Home => { app.scroll = 0; } KeyCode::Esc => { app.count_prefix = None; } _ => {} } } // Drain remaining events while event::poll(Duration::ZERO).unwrap_or(false) { let _ = event::read(); } } app.poll(); } }