use ratatui::layout::Constraint::{Length, Min};
use ratatui::prelude::*;
use ratatui::widgets::{Clear, List, ListItem, Paragraph};
use shuvarie_core::SearchHit;
use termina::event::{KeyCode, KeyEvent};
use crate::tui::utils::ctrl;
use super::add_provider::centered_rect;
use super::components::InputBuffer;
use super::list::{render_list_item_line, scroll_offset_for};
use super::theme;
pub enum HistorySearchMessage {
Open,
Input(char),
Paste(String),
Backspace,
Next,
Prev,
Submit,
Close,
Resize { viewport_height: u16 },
Results { hits: Vec<SearchHit> },
Error { error: String },
}
pub enum HistorySearchEffect {
Open,
Search { query: String },
LoadSession { id: uuid::Uuid },
Close,
}
pub struct HistorySearch {
pub open: bool,
pub query: InputBuffer,
pub hits: Vec<SearchHit>,
pub selected: usize,
pub offset: usize,
viewport_height: u16,
pub loading: bool,
pub error: Option<String>,
}
impl HistorySearch {
pub fn new() -> Self {
Self {
open: false,
query: InputBuffer::new(),
hits: Vec::new(),
selected: 0,
offset: 0,
viewport_height: 0,
loading: false,
error: None,
}
}
pub fn open(&mut self) {
self.open = true;
self.query.clear();
self.hits.clear();
self.selected = 0;
self.offset = 0;
self.loading = false;
self.error = None;
}
pub fn close(&mut self) {
self.open = false;
self.query.clear();
self.hits.clear();
self.loading = false;
self.error = None;
}
fn set_hits(&mut self, hits: Vec<SearchHit>) {
self.hits = hits;
self.loading = false;
self.error = None;
self.selected = 0;
self.recompute_offset();
}
fn recompute_offset(&mut self) {
let vh = self.viewport_height as usize;
let len = self.hits.len();
self.offset = scroll_offset_for(self.selected, self.offset, vh, len);
}
fn next(&mut self) {
if !self.hits.is_empty() {
self.selected = (self.selected + 1).min(self.hits.len() - 1);
self.recompute_offset();
}
}
fn prev(&mut self) {
if !self.hits.is_empty() {
self.selected = self.selected.saturating_sub(1);
self.recompute_offset();
}
}
pub fn map_event(&self, key: &KeyEvent) -> Option<HistorySearchMessage> {
if ctrl(key) {
return match key.code {
KeyCode::Char('n') => Some(HistorySearchMessage::Next),
KeyCode::Char('p') => Some(HistorySearchMessage::Prev),
KeyCode::Char('b') => Some(HistorySearchMessage::Backspace),
KeyCode::Char('h') => Some(HistorySearchMessage::Backspace),
KeyCode::Char('r') => Some(HistorySearchMessage::Submit),
_ => None,
};
}
match key.code {
KeyCode::Escape => Some(HistorySearchMessage::Close),
KeyCode::Down => Some(HistorySearchMessage::Next),
KeyCode::Up => Some(HistorySearchMessage::Prev),
KeyCode::Enter => Some(HistorySearchMessage::Submit),
KeyCode::Backspace => Some(HistorySearchMessage::Backspace),
KeyCode::Char(c) => Some(HistorySearchMessage::Input(c)),
_ => None,
}
}
pub fn update(&mut self, msg: HistorySearchMessage) -> Option<HistorySearchEffect> {
if !self.open
&& !matches!(
msg,
HistorySearchMessage::Open | HistorySearchMessage::Close
)
{
return None;
}
match msg {
HistorySearchMessage::Open => {
self.open();
Some(HistorySearchEffect::Open)
}
HistorySearchMessage::Close => {
self.close();
Some(HistorySearchEffect::Close)
}
HistorySearchMessage::Input(c) => {
self.query.push(c);
self.loading = true;
Some(HistorySearchEffect::Search {
query: self.query.value.clone(),
})
}
HistorySearchMessage::Paste(text) => {
self.query
.insert_str(&super::components::flatten_newlines(&text));
self.loading = true;
Some(HistorySearchEffect::Search {
query: self.query.value.clone(),
})
}
HistorySearchMessage::Backspace => {
self.query.backspace();
self.loading = true;
Some(HistorySearchEffect::Search {
query: self.query.value.clone(),
})
}
HistorySearchMessage::Next => {
self.next();
None
}
HistorySearchMessage::Prev => {
self.prev();
None
}
HistorySearchMessage::Submit => {
if self.loading || self.hits.is_empty() {
return None;
}
if let Some(hit) = self.hits.get(self.selected) {
let id = hit.session_id;
self.close();
Some(HistorySearchEffect::LoadSession { id })
} else {
None
}
}
HistorySearchMessage::Results { hits } => {
self.set_hits(hits);
None
}
HistorySearchMessage::Error { error } => {
self.loading = false;
self.error = Some(error);
self.hits.clear();
self.selected = 0;
self.recompute_offset();
None
}
HistorySearchMessage::Resize { viewport_height } => {
if self.viewport_height != viewport_height {
self.viewport_height = viewport_height;
self.recompute_offset();
}
None
}
}
}
fn snippet(content: &str) -> String {
let compact = content.split_whitespace().collect::<Vec<_>>().join(" ");
if compact.chars().count() > 160 {
let mut s: String = compact.chars().take(157).collect();
s.push('…');
s
} else {
compact
}
}
pub fn view(&self, frame: &mut Frame<'_>, area: Rect) {
if !self.open {
return;
}
let popup = centered_rect(64, 40, area);
frame.render_widget(Clear, popup);
let block = theme::overlay_block("Search history");
let inner = block.inner(popup);
frame.render_widget(block, popup);
let [input_area, status_area, list_area, hint_area] =
Layout::vertical([Length(1), Length(1), Min(0), Length(1)]).areas(inner);
let input_line = self.query.cursor_line(theme::text(), theme::accent());
let mut input_spans = vec![Span::raw("/ ").fg(theme::text_muted())];
input_spans.extend(input_line.spans);
frame.render_widget(Paragraph::new(Line::from(input_spans)), input_area);
let status = if let Some(err) = &self.error {
Paragraph::new(err.as_str()).fg(theme::error())
} else if self.loading {
Paragraph::new(Line::from(vec![
super::spinner::spinner(),
Span::raw(" Searching...").fg(theme::text_muted()),
]))
} else if self.query.value.is_empty() {
Paragraph::new("type to search all sessions").fg(theme::text_muted())
} else if self.hits.is_empty() {
Paragraph::new("no matches").fg(theme::text_muted())
} else {
Paragraph::new(format!(
"{} matches · ⌕ semantic results are merged as they arrive",
self.hits.len()
))
.fg(theme::text_muted())
};
frame.render_widget(status, status_area);
let offset = scroll_offset_for(
self.selected,
self.offset,
list_area.height as usize,
self.hits.len(),
);
let visible: Vec<ListItem> = self
.hits
.iter()
.enumerate()
.skip(offset)
.take(list_area.height as usize)
.map(|(idx, hit)| {
let role_tag = match hit.role {
shuvarie_core::MsgRole::System => "sys",
shuvarie_core::MsgRole::User => "user",
shuvarie_core::MsgRole::Assistant => "asst",
};
let source_tag = match hit.source {
shuvarie_core::SearchSource::Semantic => "⌕ ",
shuvarie_core::SearchSource::Fts => "",
};
let line = Line::from(vec![
Span::raw(format!("{} · ", hit.session_title)).fg(theme::accent()),
Span::raw(role_tag).fg(theme::text_muted()),
Span::raw(" "),
Span::raw(source_tag).fg(theme::accent()),
Span::raw(Self::snippet(&hit.content)).fg(theme::text()),
Span::raw(format!(" {:.2}", hit.score)).fg(theme::text_muted()),
]);
render_list_item_line(line, idx == self.selected)
})
.collect();
frame.render_widget(List::new(visible), list_area);
frame.render_widget(
Paragraph::new(theme::help_line(&[
("Type", "to search"),
("↑↓", "navigate"),
("Enter", "open session"),
("Esc", "close"),
]))
.fg(theme::text_muted()),
hint_area,
);
}
}
impl Default for HistorySearch {
fn default() -> Self {
Self::new()
}
}