use std::io; use std::sync::Arc; use std::time::Duration; use crossterm::{ event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, }, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; use nocontrol::{ControlPlane, Operator, Specification, manifests::ManifestState}; use ratatui::{ Frame, Terminal, backend::{Backend, CrosstermBackend}, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, }; use tokio::sync::Mutex; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputMode { Normal, Search, } struct App { control_plane: ControlPlane, manifests: Vec>, filtered_indices: Vec, list_state: ListState, input_mode: InputMode, command_input: String, search_query: String, command_history: Vec, history_index: Option, messages: Vec, should_quit: bool, fuzzy_matcher: SkimMatcherV2, } impl App { fn new(control_plane: ControlPlane) -> Self { let mut list_state = ListState::default(); list_state.select(Some(0)); Self { control_plane, manifests: Vec::new(), filtered_indices: Vec::new(), list_state, input_mode: InputMode::Normal, command_input: String::new(), search_query: String::new(), command_history: Vec::new(), history_index: None, messages: vec![ "Welcome to nocontrol TUI".to_string(), "Press / to search, type commands, ↑↓ for history/navigation".to_string(), ], should_quit: false, fuzzy_matcher: SkimMatcherV2::default(), } } async fn refresh_manifests(&mut self) { if let Ok(manifests) = self.control_plane.get_manifests().await { self.manifests = manifests; self.update_filtered_list(); } } fn update_filtered_list(&mut self) { if self.input_mode == InputMode::Search && !self.search_query.is_empty() { // Fuzzy search through manifest names and specs let mut scored_indices: Vec<(usize, i64)> = self .manifests .iter() .enumerate() .filter_map(|(idx, manifest)| { let search_text = format!( "{} {:?} {:?}", manifest.manifest.name, manifest.status.status, manifest.manifest.spec.kind() ); self.fuzzy_matcher .fuzzy_match(&search_text, &self.search_query) .map(|score| (idx, score)) }) .collect(); // Sort by score (highest first) scored_indices.sort_by(|a, b| b.1.cmp(&a.1)); self.filtered_indices = scored_indices.into_iter().map(|(idx, _)| idx).collect(); } else { // No filtering, show all manifests self.filtered_indices = (0..self.manifests.len()).collect(); } // Reset selection to first item if list changed if !self.filtered_indices.is_empty() { self.list_state.select(Some(0)); } else { self.list_state.select(None); } } fn get_selected_manifest(&self) -> Option<&ManifestState> { self.list_state .selected() .and_then(|selected_idx| self.filtered_indices.get(selected_idx)) .and_then(|&manifest_idx| self.manifests.get(manifest_idx)) } fn next(&mut self) { if self.filtered_indices.is_empty() { return; } let i = match self.list_state.selected() { Some(i) => { if i >= self.filtered_indices.len() - 1 { 0 } else { i + 1 } } None => 0, }; self.list_state.select(Some(i)); } fn previous(&mut self) { if self.filtered_indices.is_empty() { return; } let i = match self.list_state.selected() { Some(i) => { if i == 0 { self.filtered_indices.len() - 1 } else { i - 1 } } None => 0, }; self.list_state.select(Some(i)); } fn history_previous(&mut self) { if self.command_history.is_empty() { return; } let new_index = match self.history_index { None => Some(self.command_history.len() - 1), Some(0) => Some(0), // Stay at oldest Some(i) => Some(i - 1), }; if let Some(idx) = new_index { self.history_index = Some(idx); self.command_input = self.command_history[idx].clone(); } } fn history_next(&mut self) { if self.command_history.is_empty() { return; } let new_index = match self.history_index { None => None, Some(i) if i >= self.command_history.len() - 1 => { // Clear input when going past newest self.command_input.clear(); None } Some(i) => Some(i + 1), }; self.history_index = new_index; if let Some(idx) = new_index { self.command_input = self.command_history[idx].clone(); } } fn toggle_search_mode(&mut self) { self.input_mode = match self.input_mode { InputMode::Normal => { self.search_query.clear(); InputMode::Search } InputMode::Search => { self.search_query.clear(); self.update_filtered_list(); InputMode::Normal } }; } fn update_search(&mut self) { if self.input_mode == InputMode::Search { self.search_query = self.command_input.clone(); self.update_filtered_list(); } } async fn execute_command(&mut self) { let cmd = self.command_input.trim().to_string(); if cmd.is_empty() { return; } // Add to history if self.command_history.last() != Some(&cmd) { self.command_history.push(cmd.clone()); } self.history_index = None; self.messages.push(format!("> {}", cmd)); let parts: Vec<&str> = cmd.split_whitespace().collect(); match parts.as_slice() { ["get"] | ["list"] => { self.refresh_manifests().await; self.messages.push(format!( "Found {} manifest(s), showing {}", self.manifests.len(), self.filtered_indices.len() )); } ["describe"] => { if let Some(manifest) = self.get_selected_manifest() { let json = serde_json::to_string_pretty(manifest).unwrap_or_default(); self.messages.push(json); } else { self.messages.push("No manifest selected".to_string()); } } ["quit"] | ["exit"] | ["q"] => { self.should_quit = true; } ["clear"] => { self.messages.clear(); self.messages.push("Screen cleared".to_string()); } ["history"] => { self.messages.push("Command history:".to_string()); for (i, cmd) in self.command_history.iter().enumerate() { self.messages.push(format!(" {}: {}", i + 1, cmd)); } } ["help"] => { self.messages.push("Commands:".to_string()); self.messages .push(" get, list - Refresh manifest list".to_string()); self.messages .push(" describe - Show selected manifest details".to_string()); self.messages .push(" history - Show command history".to_string()); self.messages .push(" clear - Clear output messages".to_string()); self.messages .push(" quit, exit, q - Exit application".to_string()); self.messages .push(" help - Show this help".to_string()); self.messages.push("".to_string()); self.messages.push("Keys:".to_string()); self.messages .push(" / - Toggle search mode".to_string()); self.messages .push(" ↑/↓ - Navigate list OR cycle command history".to_string()); self.messages .push(" Enter - Execute command (or exit search)".to_string()); self.messages .push(" Esc - Clear input / exit search".to_string()); self.messages .push(" q - Quick quit (when input empty)".to_string()); } _ => { self.messages.push(format!( "Unknown command: {}. Type 'help' for commands.", cmd )); } } self.command_input.clear(); // Keep only last 100 messages if self.messages.len() > 100 { self.messages.drain(0..self.messages.len() - 100); } } } async fn run_app( terminal: &mut Terminal, app: Arc>>, ) -> anyhow::Result<()> where TOperator::Specifications: Send + Sync, { // Spawn refresh task let app_clone = app.clone(); tokio::spawn(async move { loop { tokio::time::sleep(Duration::from_millis(500)).await; app_clone.lock().await.refresh_manifests().await; } }); loop { // Draw UI { let mut app = app.lock().await; terminal.draw(|f| ui(f, &mut app))?; if app.should_quit { break; } } // Handle input with timeout if event::poll(Duration::from_millis(100))? && let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { let mut app = app.lock().await; match (key.code, key.modifiers, app.input_mode) { // Quick quit with 'q' when input is empty (KeyCode::Char('q'), KeyModifiers::NONE, InputMode::Normal) if app.command_input.is_empty() => { app.should_quit = true; } // Toggle search mode with '/' (KeyCode::Char('/'), KeyModifiers::NONE, _) => { app.toggle_search_mode(); } // Character input (KeyCode::Char(c), _, _) => { app.command_input.push(c); app.update_search(); } // Backspace (KeyCode::Backspace, _, _) => { app.command_input.pop(); app.update_search(); } // Enter key (KeyCode::Enter, _, InputMode::Search) => { // Exit search mode but keep filter app.input_mode = InputMode::Normal; app.command_input.clear(); } (KeyCode::Enter, _, InputMode::Normal) => { app.execute_command().await; } // Arrow keys (KeyCode::Up, _, InputMode::Normal) if !app.command_input.is_empty() => { // Navigate history when typing a command app.history_previous(); } (KeyCode::Down, _, InputMode::Normal) if !app.command_input.is_empty() => { // Navigate history when typing a command app.history_next(); } (KeyCode::Up, _, _) => { // Navigate manifest list app.previous(); } (KeyCode::Down, _, _) => { // Navigate manifest list app.next(); } // Escape key (KeyCode::Esc, _, InputMode::Search) => { app.toggle_search_mode(); app.command_input.clear(); } (KeyCode::Esc, _, InputMode::Normal) => { app.command_input.clear(); app.history_index = None; } _ => {} } } } Ok(()) } fn ui(f: &mut Frame, app: &mut App) { // Create layout let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Title Constraint::Min(10), // Main content Constraint::Length(10), // Messages Constraint::Length(3), // Command input ]) .split(f.area()); // Title let mode_text = match app.input_mode { InputMode::Normal => "NORMAL", InputMode::Search => "SEARCH", }; let title = Paragraph::new(vec![Line::from(vec![ Span::styled( "NoControl - Kubernetes-like Control Plane", Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ), Span::raw(" "), Span::styled( format!("[{}]", mode_text), Style::default() .fg(if app.input_mode == InputMode::Search { Color::Yellow } else { Color::Green }) .add_modifier(Modifier::BOLD), ), ])]) .block(Block::default().borders(Borders::ALL)); f.render_widget(title, chunks[0]); // Main content area - split into list and details let main_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) .split(chunks[1]); // Manifest list render_manifest_list(f, app, main_chunks[0]); // Manifest details render_manifest_details(f, app, main_chunks[1]); // Messages area render_messages(f, app, chunks[2]); // Command input render_command_input(f, app, chunks[3]); } fn render_manifest_list(f: &mut Frame, app: &mut App, area: Rect) { // Collect filtered manifests data before borrowing list_state let filtered_data: Vec<_> = app .filtered_indices .iter() .filter_map(|&idx| app.manifests.get(idx)) .map(|m| (m.manifest.name.clone(), m.status.status.clone())) .collect(); let items: Vec = filtered_data .iter() .map(|(name, status)| { let status_color = match status { nocontrol::manifests::ManifestStatusState::Running => Color::Green, nocontrol::manifests::ManifestStatusState::Started => Color::Yellow, nocontrol::manifests::ManifestStatusState::Pending => Color::Gray, nocontrol::manifests::ManifestStatusState::Stopping => Color::Magenta, nocontrol::manifests::ManifestStatusState::Deleting => Color::Red, nocontrol::manifests::ManifestStatusState::Errored => Color::Red, }; let status_text = format!("{:?}", status); let content = Line::from(vec![ Span::styled("● ", Style::default().fg(status_color)), Span::raw(name), Span::raw(" "), Span::styled( format!("[{}]", status_text), Style::default().fg(status_color), ), ]); ListItem::new(content) }) .collect(); let title = if app.input_mode == InputMode::Search && !app.search_query.is_empty() { format!( " Manifests ({}/{}) - Filtered ", filtered_data.len(), app.manifests.len() ) } else { format!(" Manifests ({}) ", app.manifests.len()) }; let list = List::new(items) .block(Block::default().title(title).borders(Borders::ALL)) .highlight_style( Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD), ) .highlight_symbol(">> "); f.render_stateful_widget(list, area, &mut app.list_state); } fn render_manifest_details(f: &mut Frame, app: &App, area: Rect) { let content = if let Some(manifest) = app.get_selected_manifest() { let mut lines = vec![ Line::from(vec![ Span::styled("Name: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(&manifest.manifest.name), ]), Line::from(vec![ Span::styled("Kind: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(manifest.manifest.spec.kind()), ]), Line::from(vec![ Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!("{:?}", manifest.status.status)), ]), Line::from(vec![ Span::styled( "Generation: ", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(format!("{}", manifest.generation)), ]), Line::from(vec![ Span::styled("Created: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!("{}", manifest.created)), ]), Line::from(vec![ Span::styled("Updated: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!("{}", manifest.updated)), ]), ]; if !manifest.status.events.is_empty() { lines.push(Line::from("")); lines.push(Line::from(Span::styled( "Events:", Style::default().add_modifier(Modifier::BOLD), ))); for event in manifest.status.events.iter().rev().take(5) { lines.push(Line::from(format!(" • {}", event.message))); } } if !manifest.status.changes.is_empty() { lines.push(Line::from("")); lines.push(Line::from(Span::styled( "Changes:", Style::default().add_modifier(Modifier::BOLD), ))); for change in manifest.status.changes.iter().rev().take(5) { lines.push(Line::from(format!( " • {:?} at {}", change.event, change.created ))); } } Text::from(lines) } else { Text::from("No manifest selected") }; let paragraph = Paragraph::new(content) .block(Block::default().title(" Details ").borders(Borders::ALL)) .wrap(Wrap { trim: true }); f.render_widget(paragraph, area); } fn render_messages(f: &mut Frame, app: &App, area: Rect) { let messages: Vec = app .messages .iter() .rev() .take(8) .rev() .map(|m| Line::from(m.clone())) .collect(); let paragraph = Paragraph::new(messages) .block(Block::default().title(" Output ").borders(Borders::ALL)) .wrap(Wrap { trim: false }); f.render_widget(paragraph, area); } fn render_command_input(f: &mut Frame, app: &App, area: Rect) { let (title, input_text, style) = match app.input_mode { InputMode::Normal => { let hist_info = if let Some(idx) = app.history_index { format!(" [History {}/{}]", idx + 1, app.command_history.len()) } else { String::new() }; ( format!( " Command{} (/ to search, ↑↓ for history, 'help' for commands) ", hist_info ), app.command_input.as_str(), Style::default().fg(Color::Yellow), ) } InputMode::Search => ( " Search (fuzzy) - Enter to apply, Esc to cancel ".to_string(), app.command_input.as_str(), Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ), }; let input = Paragraph::new(input_text) .style(style) .block(Block::default().title(title).borders(Borders::ALL)); f.render_widget(input, area); } /// Run the TUI with the given control plane pub async fn run(control_plane: ControlPlane) -> anyhow::Result<()> where TOperator: Operator + Send + Sync + 'static, TOperator::Specifications: Send + Sync, { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; // Create app state let app = Arc::new(Mutex::new(App::new(control_plane))); // Run TUI let res = run_app(&mut terminal, app).await; // Restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; res }