user: share candidate-browser helpers between F6/F7
F6 (learn) and F7 (compare) were duplicating the candidate-screen skeleton: outer magenta-bordered block with screen legend + title, settings row / content / help vertical split, 40/60 list/detail horizontal split, j/k/↑/↓ nav with bounds clamping. Factor out three helpers in user/widgets.rs: candidate_frame(frame, area, title) -> (settings, content, help) list_detail_split(content) -> (list, detail) handle_list_nav(events, list_state, count, on_other) Callers provide screen-specific content — settings line, empty state, per-candidate list item, detail pane, help line, extra key bindings — and the helpers absorb the common framing. Net change is small in lines (-13 src) but removes the copy-paste-and-tweak trap: F8/F9/whatever-next-screen now starts from these three calls instead of a copy of learn.rs. Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
parent
2b03dbb200
commit
d4331e80f5
3 changed files with 120 additions and 133 deletions
|
|
@ -2,15 +2,15 @@
|
||||||
// every assistant response in the current context.
|
// every assistant response in the current context.
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::Rect,
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
||||||
Frame,
|
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;
|
pub use crate::subconscious::compare::CompareCandidate;
|
||||||
|
|
||||||
|
|
@ -32,51 +32,26 @@ impl ScreenView for CompareScreen {
|
||||||
|
|
||||||
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
||||||
events: &[Event], app: &mut App) {
|
events: &[Event], app: &mut App) {
|
||||||
let n = app.compare_candidates.len();
|
widgets::handle_list_nav(events, &mut self.list_state,
|
||||||
for event in events {
|
app.compare_candidates.len(), |code| match code {
|
||||||
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 => {
|
KeyCode::Char('c') | KeyCode::Enter => {
|
||||||
let _ = self.mind_tx.send(crate::mind::MindCommand::Compare);
|
let _ = self.mind_tx.send(crate::mind::MindCommand::Compare);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
});
|
||||||
}
|
|
||||||
}
|
let (settings_area, content_area, help_area) =
|
||||||
if n > 0 {
|
widgets::candidate_frame(frame, area, "compare");
|
||||||
let sel = self.list_state.selected().unwrap_or(0).min(n - 1);
|
|
||||||
self.list_state.select(Some(sel));
|
|
||||||
}
|
|
||||||
|
|
||||||
let test_backend = crate::config::app().compare.test_backend.clone();
|
let test_backend = crate::config::app().compare.test_backend.clone();
|
||||||
let block = Block::default()
|
let (label, color) = if test_backend.is_empty() {
|
||||||
.title_top(Line::from(screen_legend()).left_aligned())
|
("(unset — set compare.test_backend)".to_string(), Color::Red)
|
||||||
.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)
|
|
||||||
} else {
|
} else {
|
||||||
(test_backend.as_str(), Color::Yellow)
|
(test_backend, Color::Yellow)
|
||||||
};
|
};
|
||||||
frame.render_widget(Paragraph::new(Line::from(vec![
|
frame.render_widget(Paragraph::new(Line::from(vec![
|
||||||
Span::raw(" test model: "),
|
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);
|
])), settings_area);
|
||||||
|
|
||||||
let candidates = &app.compare_candidates;
|
let candidates = &app.compare_candidates;
|
||||||
|
|
@ -94,9 +69,7 @@ impl ScreenView for CompareScreen {
|
||||||
}
|
}
|
||||||
frame.render_widget(Paragraph::new(lines), content_area);
|
frame.render_widget(Paragraph::new(lines), content_area);
|
||||||
} else {
|
} else {
|
||||||
let [list_area, detail_area] = Layout::horizontal([
|
let (list_area, detail_area) = widgets::list_detail_split(content_area);
|
||||||
Constraint::Percentage(40), Constraint::Percentage(60),
|
|
||||||
]).areas(content_area);
|
|
||||||
|
|
||||||
let items: Vec<ListItem> = candidates.iter().map(|c| ListItem::new(Line::from(vec![
|
let items: Vec<ListItem> = candidates.iter().map(|c| ListItem::new(Line::from(vec![
|
||||||
Span::styled(format!("#{:<3} ", c.entry_idx), Style::default().fg(Color::DarkGray)),
|
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::styled(" j/k/\u{2191}\u{2193}", Style::default().fg(Color::Cyan)),
|
||||||
Span::raw("=nav "),
|
Span::raw("=nav "),
|
||||||
Span::styled("c/Enter", Style::default().fg(Color::Green)),
|
Span::styled("c/Enter", Style::default().fg(Color::Green)),
|
||||||
Span::raw("=run "),
|
Span::raw("=run "),
|
||||||
]);
|
])), help_area);
|
||||||
frame.render_widget(
|
|
||||||
Paragraph::new(help),
|
|
||||||
Rect { y: area.y + area.height - 1, height: 1, ..area },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ use ratatui::{
|
||||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
||||||
Frame,
|
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.
|
/// A candidate response identified for fine-tuning.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -86,28 +86,16 @@ impl ScreenView for LearnScreen {
|
||||||
|
|
||||||
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
||||||
events: &[Event], app: &mut App) {
|
events: &[Event], app: &mut App) {
|
||||||
|
let selected_idx = self.list_state.selected();
|
||||||
// Handle input first (before borrowing candidates for rendering)
|
widgets::handle_list_nav(events, &mut self.list_state,
|
||||||
let candidate_count = app.finetune_candidates.len();
|
app.finetune_candidates.len(), |code| match code {
|
||||||
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);
|
|
||||||
let max = candidate_count.saturating_sub(1);
|
|
||||||
self.list_state.select(Some((i + 1).min(max)));
|
|
||||||
}
|
|
||||||
KeyCode::Char('a') => {
|
KeyCode::Char('a') => {
|
||||||
if let Some(idx) = self.selected_idx() {
|
if let Some(idx) = selected_idx {
|
||||||
app.finetune_action(idx, CandidateStatus::Approved);
|
app.finetune_action(idx, CandidateStatus::Approved);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
if let Some(idx) = self.selected_idx() {
|
if let Some(idx) = selected_idx {
|
||||||
app.finetune_action(idx, CandidateStatus::Rejected);
|
app.finetune_action(idx, CandidateStatus::Rejected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -116,51 +104,25 @@ impl ScreenView for LearnScreen {
|
||||||
let _ = self.mind_tx.send(
|
let _ = self.mind_tx.send(
|
||||||
crate::mind::MindCommand::SetLearnGenerateAlternates(!current));
|
crate::mind::MindCommand::SetLearnGenerateAlternates(!current));
|
||||||
}
|
}
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('s') => { app.finetune_send_approved(); }
|
||||||
app.finetune_send_approved();
|
|
||||||
}
|
|
||||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||||
// Raise threshold 10× (less sensitive — fewer candidates).
|
|
||||||
let new = crate::config::app().learn.threshold * 10.0;
|
let new = crate::config::app().learn.threshold * 10.0;
|
||||||
let _ = self.mind_tx.send(
|
let _ = self.mind_tx.send(crate::mind::MindCommand::SetLearnThreshold(new));
|
||||||
crate::mind::MindCommand::SetLearnThreshold(new));
|
|
||||||
}
|
}
|
||||||
KeyCode::Char('-') => {
|
KeyCode::Char('-') => {
|
||||||
// Lower threshold 10× (more sensitive — more candidates).
|
|
||||||
let new = crate::config::app().learn.threshold / 10.0;
|
let new = crate::config::app().learn.threshold / 10.0;
|
||||||
let _ = self.mind_tx.send(
|
let _ = self.mind_tx.send(crate::mind::MindCommand::SetLearnThreshold(new));
|
||||||
crate::mind::MindCommand::SetLearnThreshold(new));
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure selection is valid
|
let (settings_area, content_area, help_area) =
|
||||||
if candidate_count > 0 {
|
widgets::candidate_frame(frame, area, "learn");
|
||||||
let sel = self.list_state.selected().unwrap_or(0).min(candidate_count - 1);
|
|
||||||
self.list_state.select(Some(sel));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now render
|
|
||||||
let (threshold, gen_on) = {
|
let (threshold, gen_on) = {
|
||||||
let app_cfg = crate::config::app();
|
let app_cfg = crate::config::app();
|
||||||
(app_cfg.learn.threshold, app_cfg.learn.generate_alternates)
|
(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![
|
let settings = Line::from(vec![
|
||||||
Span::raw(" thresh: "),
|
Span::raw(" thresh: "),
|
||||||
Span::styled(format!("{:e}", threshold), Style::default().fg(Color::Yellow)),
|
Span::styled(format!("{:e}", threshold), Style::default().fg(Color::Yellow)),
|
||||||
|
|
@ -177,11 +139,7 @@ impl ScreenView for LearnScreen {
|
||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
render_empty(frame, content_area, app);
|
render_empty(frame, content_area, app);
|
||||||
} else {
|
} else {
|
||||||
// Layout: list on left, detail on right
|
let (list_area, detail_area) = widgets::list_detail_split(content_area);
|
||||||
let [list_area, detail_area] = Layout::horizontal([
|
|
||||||
Constraint::Percentage(40),
|
|
||||||
Constraint::Percentage(60),
|
|
||||||
]).areas(content_area);
|
|
||||||
|
|
||||||
// Render candidate list
|
// Render candidate list
|
||||||
let items: Vec<ListItem> = candidates.iter().map(|c| {
|
let items: Vec<ListItem> = candidates.iter().map(|c| {
|
||||||
|
|
@ -217,8 +175,7 @@ impl ScreenView for LearnScreen {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render help at bottom (always, even when empty)
|
frame.render_widget(Paragraph::new(Line::from(vec![
|
||||||
let help = Line::from(vec![
|
|
||||||
Span::styled(" j/k/\u{2191}\u{2193}", Style::default().fg(Color::Cyan)),
|
Span::styled(" j/k/\u{2191}\u{2193}", Style::default().fg(Color::Cyan)),
|
||||||
Span::raw("=nav "),
|
Span::raw("=nav "),
|
||||||
Span::styled("a", Style::default().fg(Color::Green)),
|
Span::styled("a", Style::default().fg(Color::Green)),
|
||||||
|
|
@ -231,13 +188,7 @@ impl ScreenView for LearnScreen {
|
||||||
Span::raw("=send "),
|
Span::raw("=send "),
|
||||||
Span::styled("+/-", Style::default().fg(Color::Cyan)),
|
Span::styled("+/-", Style::default().fg(Color::Cyan)),
|
||||||
Span::raw("=thresh "),
|
Span::raw("=thresh "),
|
||||||
]);
|
])), help_area);
|
||||||
let help_area = Rect {
|
|
||||||
y: area.y + area.height - 1,
|
|
||||||
height: 1,
|
|
||||||
..area
|
|
||||||
};
|
|
||||||
frame.render_widget(Paragraph::new(help), help_area);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
// SectionTree — expand/collapse tree renderer for ContextSection
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue