diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index ba072018..2f22e099 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -1,19 +1,23 @@ use std::error::Error; use std::io; +use std::time::Duration; use client::{ BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary, list_backend_workers, }; -use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind}; -use ratatui::Frame; +use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; +use ratatui::widgets::Paragraph; +use ratatui::{Frame, Terminal, TerminalOptions, Viewport}; use crate::console; +const MAX_ROWS: usize = 10; +const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4; + pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box> { let response = list_backend_workers(&target).await.map_err(|error| { io::Error::other(format!( @@ -55,29 +59,26 @@ fn pick_worker( .then_with(|| a.label.cmp(&b.label)) .then_with(|| a.worker_id.cmp(&b.worker_id)) }); - let mut state = BackendWorkerPickerState::new(target, workers); - let mut terminal = ratatui::Terminal::new(CrosstermBackend::new(io::stdout()))?; - terminal.clear()?; + workers.truncate(MAX_ROWS); + let mut state = BackendWorkerPickerState::new(target, workers); + let mut terminal = make_inline_terminal()?; loop { - terminal.draw(|frame| draw(frame, &mut state))?; - match event::read()? { - CrosstermEvent::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) => match code { - KeyCode::Up | KeyCode::Char('k') => state.previous(), - KeyCode::Down | KeyCode::Char('j') => state.next(), - KeyCode::Enter => return Ok(state.selected_worker().clone()), - KeyCode::Esc | KeyCode::Char('q') => { - return Err(Box::new(io::Error::other( - "Backend worker picker cancelled", - ))); - } - _ => {} - }, - _ => {} + terminal.draw(|frame| draw(frame, &state))?; + match poll_event()? { + None => continue, + Some(Action::Up) => state.previous(), + Some(Action::Down) => state.next(), + Some(Action::Submit) => { + close_viewport(&mut terminal)?; + return Ok(state.selected_worker().clone()); + } + Some(Action::Cancel) => { + close_viewport(&mut terminal)?; + return Err(Box::new(io::Error::other( + "Backend worker picker cancelled", + ))); + } } } } @@ -98,10 +99,9 @@ impl BackendWorkerPickerState { } fn next(&mut self) { - if self.workers.is_empty() { - return; + if self.selected + 1 < self.workers.len() { + self.selected += 1; } - self.selected = (self.selected + 1).min(self.workers.len() - 1); } fn previous(&mut self) { @@ -113,119 +113,192 @@ impl BackendWorkerPickerState { } } -fn draw(frame: &mut Frame<'_>, state: &mut BackendWorkerPickerState) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(4), - Constraint::Min(8), - Constraint::Length(5), - ]) - .split(frame.area()); - - draw_header(frame, chunks[0], state); - draw_list(frame, chunks[1], state); - draw_details(frame, chunks[2], state.selected_worker()); +fn make_inline_terminal() -> io::Result>> { + let backend = CrosstermBackend::new(io::stdout()); + Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(VIEWPORT_LINES), + }, + ) } -fn draw_header(frame: &mut Frame<'_>, area: Rect, state: &BackendWorkerPickerState) { - let workspace = state - .target - .workspace_id - .as_deref() - .unwrap_or("unscoped backend"); - let runtime = state.target.runtime_id.as_deref().unwrap_or("all runtimes"); - let text = vec![ - Line::from(vec![ - Span::styled( - "Backend runtime workers", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(format!(" {}", state.target.base_url)), - ]), - Line::from(format!("workspace: {workspace} runtime: {runtime}")), - Line::from("↑/↓ or k/j select Enter attach q/Esc cancel"), - ]; +fn close_viewport(terminal: &mut Terminal>) -> io::Result<()> { + let area = terminal.get_frame().area(); + let last_row = area.bottom().saturating_sub(1); + terminal.set_cursor_position((0, last_row))?; + use std::io::Write; + let mut out = io::stdout(); + out.write_all(b"\r\n")?; + out.flush()?; + Ok(()) +} + +enum Action { + Up, + Down, + Submit, + Cancel, +} + +fn poll_event() -> io::Result> { + if !event::poll(Duration::from_millis(100))? { + return Ok(None); + } + match event::read()? { + TermEvent::Key(k) if k.kind != KeyEventKind::Release => { + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + Ok(match k.code { + KeyCode::Up => Some(Action::Up), + KeyCode::Down => Some(Action::Down), + KeyCode::Char('k') if !ctrl => Some(Action::Up), + KeyCode::Char('j') if !ctrl => Some(Action::Down), + KeyCode::Enter => Some(Action::Submit), + KeyCode::Esc => Some(Action::Cancel), + KeyCode::Char('c') if ctrl => Some(Action::Cancel), + _ => None, + }) + } + _ => Ok(None), + } +} + +fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) { + let area = frame.area(); + let mut constraints: Vec = Vec::with_capacity(state.workers.len() + 3); + constraints.push(Constraint::Length(1)); + for _ in &state.workers { + constraints.push(Constraint::Length(1)); + } + constraints.push(Constraint::Length(1)); + constraints.push(Constraint::Length(1)); + let layout = Layout::vertical(constraints).split(area); + frame.render_widget( - Paragraph::new(text).block(Block::default().borders(Borders::ALL)), - area, + Paragraph::new(Line::from(vec![Span::styled( + picker_title(&state.target), + Style::default().add_modifier(Modifier::BOLD), + )])), + layout[0], + ); + + for (i, worker) in state.workers.iter().enumerate() { + frame.render_widget( + Paragraph::new(row_line(worker, i == state.selected)), + layout[i + 1], + ); + } + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)), + Span::raw(" select "), + Span::styled("[enter]", Style::default().fg(Color::Green)), + Span::raw(" attach "), + Span::styled("[esc]", Style::default().fg(Color::Yellow)), + Span::raw(" cancel"), + ])), + layout[state.workers.len() + 1], ); } -fn draw_list(frame: &mut Frame<'_>, area: Rect, state: &mut BackendWorkerPickerState) { - let items: Vec<_> = state - .workers - .iter() - .map(|worker| ListItem::new(worker_row(worker))) - .collect(); - let mut list_state = ListState::default().with_selected(Some(state.selected)); - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title("Workers")) - .highlight_symbol("▶ ") - .highlight_style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ); - frame.render_stateful_widget(list, area, &mut list_state); +fn picker_title(target: &BackendRuntimeListTarget) -> String { + let workspace = target + .workspace_id + .as_deref() + .map(short_text) + .unwrap_or_else(|| "unscoped".to_string()); + let runtime = target + .runtime_id + .as_deref() + .map(short_text) + .unwrap_or_else(|| "all runtimes".to_string()); + format!("backend workers workspace: {workspace} runtime: {runtime}") } -fn worker_row(worker: &BackendWorkerSummary) -> Line<'static> { +fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> { + let marker = if selected { "▶ " } else { " " }; + let id_style = if selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + let preview_style = if selected { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray) + }; + let label = if worker.label.is_empty() { worker.worker_id.as_str() } else { worker.label.as_str() }; let profile = worker.profile.as_deref().unwrap_or("-"); - let wd = working_directory_text(worker); + Line::from(vec![ + Span::raw(marker), + Span::styled(short_worker_id(worker), id_style), + Span::raw(" "), Span::styled( - format!("{}:{}", worker.runtime_id, worker.worker_id), - Style::default().fg(Color::Cyan), + format!("[{}]", worker.state), + state_style(worker.state.as_str()), ), - Span::raw(format!(" {label}")), - Span::raw(format!(" profile:{profile}")), - Span::raw(format!(" state:{}", worker.state)), - Span::raw(format!(" wd:{wd}")), + Span::raw(" "), + Span::styled( + format!("profile:{profile}"), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" "), + Span::styled( + working_directory_text(worker), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" "), + Span::styled(label.to_string(), preview_style), ]) } -fn draw_details(frame: &mut Frame<'_>, area: Rect, worker: &BackendWorkerSummary) { - let profile = worker.profile.as_deref().unwrap_or("-"); - let role = worker.role.as_deref().unwrap_or("-"); - let text = vec![ - Line::from(format!( - "runtime={} worker={} host={}", - worker.runtime_id, worker.worker_id, worker.host_id - )), - Line::from(format!( - "label={} role={} profile={} state={}", - worker.label, role, profile, worker.state - )), - Line::from(format!( - "working_directory={}", - working_directory_text(worker) - )), - ]; - frame.render_widget( - Paragraph::new(text) - .block( - Block::default() - .borders(Borders::ALL) - .title("Selected worker"), - ) - .wrap(Wrap { trim: true }), - area, - ); +fn state_style(state: &str) -> Style { + match state { + "running" | "idle" | "active" => Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + "stopped" | "complete" => Style::default().fg(Color::Yellow), + "failed" | "error" => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + _ => Style::default().fg(Color::Yellow), + } +} + +fn short_worker_id(worker: &BackendWorkerSummary) -> String { + format!( + "{}:{}", + short_text(&worker.runtime_id), + short_text(&worker.worker_id) + ) +} + +fn short_text(text: &str) -> String { + const MAX: usize = 24; + let mut chars = text.chars(); + let shortened: String = chars.by_ref().take(MAX).collect(); + if chars.next().is_some() { + format!("{shortened}…") + } else { + shortened + } } fn working_directory_text(worker: &BackendWorkerSummary) -> String { let Some(wd) = worker.working_directory.as_ref() else { - return "-".to_string(); + return "wd:—".to_string(); }; let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown"); format!( - "{}:{} {} {}", + "wd:{}:{} {} {}", wd.repository_id, wd.working_directory_id, wd.status, cleanliness ) } @@ -268,15 +341,29 @@ mod tests { } #[test] - fn worker_row_contains_backend_authority_fields() { - let row = worker_row(&worker("runtime-a", "worker-b", Some("default"))); + fn worker_row_matches_inline_picker_shape() { + let row = row_line(&worker("runtime-a", "worker-b", Some("default")), true); let text = row .spans .into_iter() .map(|span| span.content) .collect::(); - assert!(text.contains("runtime-a:worker-b")); + assert!(text.starts_with("▶ runtime-a:worker-b")); + assert!(text.contains("[running]")); assert!(text.contains("profile:default")); - assert!(text.contains("state:running")); + assert!(text.contains("wd:—")); + } + + #[test] + fn picker_title_uses_backend_worker_wording() { + let target = BackendRuntimeListTarget::new( + "http://127.0.0.1:8787", + Some("workspace-abcdef".to_string()), + None, + ); + assert_eq!( + picker_title(&target), + "backend workers workspace: workspace-abcdef runtime: all runtimes" + ); } }