diff --git a/src/user/compare.rs b/src/user/compare.rs index 74fb10d..2969b91 100644 --- a/src/user/compare.rs +++ b/src/user/compare.rs @@ -2,15 +2,15 @@ // every assistant response in the current context. use ratatui::{ - layout::{Constraint, Layout, Rect}, + layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, }; -use ratatui::crossterm::event::{Event, KeyCode, KeyEvent}; +use ratatui::crossterm::event::{Event, KeyCode}; -use super::{App, ScreenView, screen_legend, truncate}; +use super::{App, ScreenView, truncate, widgets}; pub use crate::subconscious::compare::CompareCandidate; @@ -32,51 +32,26 @@ impl ScreenView for CompareScreen { fn tick(&mut self, frame: &mut Frame, area: Rect, events: &[Event], app: &mut App) { - let n = app.compare_candidates.len(); - for event in events { - if let Event::Key(KeyEvent { code, .. }) = event { - match code { - KeyCode::Up | KeyCode::Char('k') => { - let i = self.list_state.selected().unwrap_or(0); - self.list_state.select(Some(i.saturating_sub(1))); - } - KeyCode::Down | KeyCode::Char('j') => { - let i = self.list_state.selected().unwrap_or(0); - self.list_state.select(Some((i + 1).min(n.saturating_sub(1)))); - } - KeyCode::Char('c') | KeyCode::Enter => { - let _ = self.mind_tx.send(crate::mind::MindCommand::Compare); - } - _ => {} + widgets::handle_list_nav(events, &mut self.list_state, + app.compare_candidates.len(), |code| match code { + KeyCode::Char('c') | KeyCode::Enter => { + let _ = self.mind_tx.send(crate::mind::MindCommand::Compare); } - } - } - if n > 0 { - let sel = self.list_state.selected().unwrap_or(0).min(n - 1); - self.list_state.select(Some(sel)); - } + _ => {} + }); + + let (settings_area, content_area, help_area) = + widgets::candidate_frame(frame, area, "compare"); let test_backend = crate::config::app().compare.test_backend.clone(); - let block = Block::default() - .title_top(Line::from(screen_legend()).left_aligned()) - .title_top(Line::from(" compare ").right_aligned()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Magenta)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let [settings_area, content_area] = Layout::vertical([ - Constraint::Length(1), Constraint::Min(0), - ]).areas(inner); - - let backend_label = if test_backend.is_empty() { - ("(unset — set compare.test_backend)", Color::Red) + let (label, color) = if test_backend.is_empty() { + ("(unset — set compare.test_backend)".to_string(), Color::Red) } else { - (test_backend.as_str(), Color::Yellow) + (test_backend, Color::Yellow) }; frame.render_widget(Paragraph::new(Line::from(vec![ Span::raw(" test model: "), - Span::styled(backend_label.0.to_string(), Style::default().fg(backend_label.1)), + Span::styled(label, Style::default().fg(color)), ])), settings_area); let candidates = &app.compare_candidates; @@ -94,9 +69,7 @@ impl ScreenView for CompareScreen { } frame.render_widget(Paragraph::new(lines), content_area); } else { - let [list_area, detail_area] = Layout::horizontal([ - Constraint::Percentage(40), Constraint::Percentage(60), - ]).areas(content_area); + let (list_area, detail_area) = widgets::list_detail_split(content_area); let items: Vec = candidates.iter().map(|c| ListItem::new(Line::from(vec![ Span::styled(format!("#{:<3} ", c.entry_idx), Style::default().fg(Color::DarkGray)), @@ -128,15 +101,11 @@ impl ScreenView for CompareScreen { } } - let help = Line::from(vec![ + frame.render_widget(Paragraph::new(Line::from(vec![ Span::styled(" j/k/\u{2191}\u{2193}", Style::default().fg(Color::Cyan)), Span::raw("=nav "), Span::styled("c/Enter", Style::default().fg(Color::Green)), Span::raw("=run "), - ]); - frame.render_widget( - Paragraph::new(help), - Rect { y: area.y + area.height - 1, height: 1, ..area }, - ); + ])), help_area); } } diff --git a/src/user/learn.rs b/src/user/learn.rs index 78c16d0..7984bab 100644 --- a/src/user/learn.rs +++ b/src/user/learn.rs @@ -10,9 +10,9 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, }; -use ratatui::crossterm::event::{Event, KeyCode, KeyEvent}; +use ratatui::crossterm::event::{Event, KeyCode}; -use super::{App, ScreenView, screen_legend, truncate}; +use super::{App, ScreenView, truncate, widgets}; /// A candidate response identified for fine-tuning. #[derive(Clone, Debug)] @@ -86,81 +86,43 @@ impl ScreenView for LearnScreen { fn tick(&mut self, frame: &mut Frame, area: Rect, events: &[Event], app: &mut App) { - - // Handle input first (before borrowing candidates for rendering) - let candidate_count = app.finetune_candidates.len(); - for event in events { - if let Event::Key(KeyEvent { code, .. }) = event { - match code { - KeyCode::Up | KeyCode::Char('k') => { - let i = self.list_state.selected().unwrap_or(0); - self.list_state.select(Some(i.saturating_sub(1))); + let selected_idx = self.list_state.selected(); + widgets::handle_list_nav(events, &mut self.list_state, + app.finetune_candidates.len(), |code| match code { + KeyCode::Char('a') => { + if let Some(idx) = selected_idx { + app.finetune_action(idx, CandidateStatus::Approved); } - KeyCode::Down | KeyCode::Char('j') => { - let i = self.list_state.selected().unwrap_or(0); - let max = candidate_count.saturating_sub(1); - self.list_state.select(Some((i + 1).min(max))); - } - KeyCode::Char('a') => { - if let Some(idx) = self.selected_idx() { - app.finetune_action(idx, CandidateStatus::Approved); - } - } - KeyCode::Char('r') => { - if let Some(idx) = self.selected_idx() { - app.finetune_action(idx, CandidateStatus::Rejected); - } - } - KeyCode::Char('g') => { - let current = crate::config::app().learn.generate_alternates; - let _ = self.mind_tx.send( - crate::mind::MindCommand::SetLearnGenerateAlternates(!current)); - } - KeyCode::Char('s') => { - app.finetune_send_approved(); - } - KeyCode::Char('+') | KeyCode::Char('=') => { - // Raise threshold 10× (less sensitive — fewer candidates). - let new = crate::config::app().learn.threshold * 10.0; - let _ = self.mind_tx.send( - crate::mind::MindCommand::SetLearnThreshold(new)); - } - KeyCode::Char('-') => { - // Lower threshold 10× (more sensitive — more candidates). - let new = crate::config::app().learn.threshold / 10.0; - let _ = self.mind_tx.send( - crate::mind::MindCommand::SetLearnThreshold(new)); - } - _ => {} } - } - } + KeyCode::Char('r') => { + if let Some(idx) = selected_idx { + app.finetune_action(idx, CandidateStatus::Rejected); + } + } + KeyCode::Char('g') => { + let current = crate::config::app().learn.generate_alternates; + let _ = self.mind_tx.send( + crate::mind::MindCommand::SetLearnGenerateAlternates(!current)); + } + KeyCode::Char('s') => { app.finetune_send_approved(); } + KeyCode::Char('+') | KeyCode::Char('=') => { + let new = crate::config::app().learn.threshold * 10.0; + let _ = self.mind_tx.send(crate::mind::MindCommand::SetLearnThreshold(new)); + } + KeyCode::Char('-') => { + let new = crate::config::app().learn.threshold / 10.0; + let _ = self.mind_tx.send(crate::mind::MindCommand::SetLearnThreshold(new)); + } + _ => {} + }); - // Ensure selection is valid - if candidate_count > 0 { - let sel = self.list_state.selected().unwrap_or(0).min(candidate_count - 1); - self.list_state.select(Some(sel)); - } + let (settings_area, content_area, help_area) = + widgets::candidate_frame(frame, area, "learn"); - // Now render let (threshold, gen_on) = { let app_cfg = crate::config::app(); (app_cfg.learn.threshold, app_cfg.learn.generate_alternates) }; - let block = Block::default() - .title_top(Line::from(screen_legend()).left_aligned()) - .title_top(Line::from(" learn ").right_aligned()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Magenta)); - let inner = block.inner(area); - frame.render_widget(block, area); - - // Split inner: top line for settings, rest for content. - let [settings_area, content_area] = Layout::vertical([ - Constraint::Length(1), - Constraint::Min(0), - ]).areas(inner); - let settings = Line::from(vec![ Span::raw(" thresh: "), Span::styled(format!("{:e}", threshold), Style::default().fg(Color::Yellow)), @@ -177,11 +139,7 @@ impl ScreenView for LearnScreen { if candidates.is_empty() { render_empty(frame, content_area, app); } else { - // Layout: list on left, detail on right - let [list_area, detail_area] = Layout::horizontal([ - Constraint::Percentage(40), - Constraint::Percentage(60), - ]).areas(content_area); + let (list_area, detail_area) = widgets::list_detail_split(content_area); // Render candidate list let items: Vec = candidates.iter().map(|c| { @@ -217,8 +175,7 @@ impl ScreenView for LearnScreen { } } - // Render help at bottom (always, even when empty) - let help = Line::from(vec![ + frame.render_widget(Paragraph::new(Line::from(vec![ Span::styled(" j/k/\u{2191}\u{2193}", Style::default().fg(Color::Cyan)), Span::raw("=nav "), Span::styled("a", Style::default().fg(Color::Green)), @@ -231,13 +188,7 @@ impl ScreenView for LearnScreen { Span::raw("=send "), Span::styled("+/-", Style::default().fg(Color::Cyan)), Span::raw("=thresh "), - ]); - let help_area = Rect { - y: area.y + area.height - 1, - height: 1, - ..area - }; - frame.render_widget(Paragraph::new(help), help_area); + ])), help_area); } } diff --git a/src/user/widgets.rs b/src/user/widgets.rs index 6b2a11d..49f3e3b 100644 --- a/src/user/widgets.rs +++ b/src/user/widgets.rs @@ -109,6 +109,73 @@ pub fn tree_legend() -> Line<'static> { ) } +// --------------------------------------------------------------------------- +// Candidate-browser screen skeleton (F6 learn, F7 compare, future screens) +// --------------------------------------------------------------------------- + +use ratatui::{ + layout::{Constraint, Layout, Rect}, + widgets::ListState, + crossterm::event::{Event, KeyEvent}, + Frame, +}; + +/// Frame a candidate-browser screen: outer magenta-bordered block with +/// the screen legend on the left and `title` on the right, split into +/// (settings_row, content_area, help_row). Caller renders into the +/// three sub-areas. +pub fn candidate_frame(frame: &mut Frame, area: Rect, title: &str) -> (Rect, Rect, Rect) { + let block = Block::default() + .title_top(Line::from(super::screen_legend()).left_aligned()) + .title_top(Line::from(format!(" {} ", title)).right_aligned()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)); + let inner = block.inner(area); + frame.render_widget(block, area); + let [settings, content] = Layout::vertical([ + Constraint::Length(1), Constraint::Min(0), + ]).areas(inner); + let help = Rect { y: area.y + area.height - 1, height: 1, ..area }; + (settings, content, help) +} + +/// 40/60 horizontal split for list + detail panes within the content area. +pub fn list_detail_split(content: Rect) -> (Rect, Rect) { + let [list, detail] = Layout::horizontal([ + Constraint::Percentage(40), Constraint::Percentage(60), + ]).areas(content); + (list, detail) +} + +/// Handle j/k/↑/↓ list navigation and keep the selection in bounds. +/// Any other key is passed to `on_other` for screen-specific handling. +pub fn handle_list_nav( + events: &[Event], + list_state: &mut ListState, + count: usize, + mut on_other: impl FnMut(KeyCode), +) { + for event in events { + if let Event::Key(KeyEvent { code, .. }) = event { + match code { + KeyCode::Up | KeyCode::Char('k') => { + let i = list_state.selected().unwrap_or(0); + list_state.select(Some(i.saturating_sub(1))); + } + KeyCode::Down | KeyCode::Char('j') => { + let i = list_state.selected().unwrap_or(0); + list_state.select(Some((i + 1).min(count.saturating_sub(1)))); + } + _ => on_other(*code), + } + } + } + if count > 0 { + let sel = list_state.selected().unwrap_or(0).min(count - 1); + list_state.select(Some(sel)); + } +} + // --------------------------------------------------------------------------- // SectionTree — expand/collapse tree renderer for ContextSection