use crate::error::Result;
use crate::parser::{parse_session, ExecutionNode};
use crate::storage::{SessionFile, SessionIndex};
use chrono::TimeZone;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
Frame,
};
#[derive(Debug, Clone, PartialEq)]
pub enum SearchContext {
Global,
Project(String),
Session(String),
}
#[derive(Debug, Clone)]
pub struct SearchResultItem {
pub title: String,
pub subtitle: String,
pub preview: String,
pub id: String,
#[allow(dead_code)]
pub score: f64,
}
pub struct SearchModal {
pub context: SearchContext,
pub query: String,
pub cursor_pos: usize,
pub results: Vec<SearchResultItem>,
pub list_state: ListState,
pub is_active: bool,
pub status: String,
pub total_results: usize,
}
impl SearchModal {
pub fn new(context: SearchContext) -> Self {
let mut list_state = ListState::default();
list_state.select(Some(0));
SearchModal {
context,
query: String::new(),
cursor_pos: 0,
results: Vec::new(),
list_state,
is_active: false,
status: String::new(),
total_results: 0,
}
}
pub fn activate(&mut self) {
self.is_active = true;
self.query.clear();
self.cursor_pos = 0;
self.results.clear();
self.list_state.select(Some(0));
self.update_search().ok(); }
pub fn deactivate(&mut self) {
self.is_active = false;
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<SearchAction> {
if !self.is_active {
return Ok(SearchAction::None);
}
match (key.code, key.modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
self.deactivate();
Ok(SearchAction::Cancel)
}
(KeyCode::Enter, _) => {
if let Some(selected) = self.list_state.selected() {
if let Some(result) = self.results.get(selected) {
let action = match &self.context {
SearchContext::Global | SearchContext::Project(_) => {
SearchAction::SelectSession(result.id.clone())
}
SearchContext::Session(_) => {
SearchAction::SelectNode(result.id.clone())
}
};
self.deactivate();
return Ok(action);
}
}
Ok(SearchAction::None)
}
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
self.next_result();
Ok(SearchAction::None)
}
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
self.previous_result();
Ok(SearchAction::None)
}
(KeyCode::Char(c), KeyModifiers::NONE) | (KeyCode::Char(c), KeyModifiers::SHIFT) => {
self.query.insert(self.cursor_pos, c);
self.cursor_pos += 1;
self.run_search();
Ok(SearchAction::None)
}
(KeyCode::Backspace, _) => {
if self.cursor_pos > 0 {
self.query.remove(self.cursor_pos - 1);
self.cursor_pos -= 1;
self.run_search();
}
Ok(SearchAction::None)
}
(KeyCode::Delete, _) => {
if self.cursor_pos < self.query.len() {
self.query.remove(self.cursor_pos);
self.run_search();
}
Ok(SearchAction::None)
}
(KeyCode::Left, _) => {
if self.cursor_pos > 0 {
self.cursor_pos -= 1;
}
Ok(SearchAction::None)
}
(KeyCode::Right, _) => {
if self.cursor_pos < self.query.len() {
self.cursor_pos += 1;
}
Ok(SearchAction::None)
}
(KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
self.cursor_pos = 0;
Ok(SearchAction::None)
}
(KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
self.cursor_pos = self.query.len();
Ok(SearchAction::None)
}
_ => Ok(SearchAction::None),
}
}
fn next_result(&mut self) {
if self.results.is_empty() {
return;
}
let selected = self.list_state.selected().unwrap_or(0);
let next = if selected >= self.results.len() - 1 {
0
} else {
selected + 1
};
self.list_state.select(Some(next));
}
fn previous_result(&mut self) {
if self.results.is_empty() {
return;
}
let selected = self.list_state.selected().unwrap_or(0);
let prev = if selected == 0 {
self.results.len() - 1
} else {
selected - 1
};
self.list_state.select(Some(prev));
}
fn run_search(&mut self) {
if let Err(e) = self.update_search() {
self.results.clear();
self.status = format!("Search error: {}", e);
}
}
fn update_search(&mut self) -> Result<()> {
match &self.context.clone() {
SearchContext::Global => self.search_global_sessions(),
SearchContext::Project(project) => self.search_project_sessions(project),
SearchContext::Session(session_id) => self.search_session_content(session_id),
}
}
fn search_global_sessions(&mut self) -> Result<()> {
let (text, errors_only, tool) = parse_query(&self.query);
let index = SessionIndex::new()?;
let results = index.search_sessions(&text, None, errors_only, tool.as_deref())?;
self.total_results = results.len();
self.results = results.iter().map(session_to_result_item).collect();
self.status = format!("{} sessions", self.results.len());
if !self.results.is_empty() {
self.list_state.select(Some(0));
}
Ok(())
}
fn search_project_sessions(&mut self, project: &str) -> Result<()> {
let (text, errors_only, tool) = parse_query(&self.query);
let index = SessionIndex::new()?;
let results = index.search_sessions(&text, Some(project), errors_only, tool.as_deref())?;
self.total_results = results.len();
self.results = results.iter().map(session_to_result_item).collect();
self.status = format!("{} sessions in {}", self.results.len(), project);
if !self.results.is_empty() {
self.list_state.select(Some(0));
}
Ok(())
}
fn search_session_content(&mut self, session_id: &str) -> Result<()> {
let index = SessionIndex::new()?;
let session_file = index
.find_by_id(session_id)?
.ok_or_else(|| crate::error::HindsightError::SessionNotFound(session_id.to_string()))?;
let session = parse_session(&session_file.path)?;
self.total_results = session.nodes.len();
let query_lower = self.query.to_lowercase();
let matching_nodes: Vec<(usize, &ExecutionNode)> = if self.query.is_empty() {
session.nodes.iter().enumerate().collect()
} else {
session
.nodes
.iter()
.enumerate()
.filter(|(_, node)| {
let category = get_node_category(node);
if category.to_lowercase().contains(&query_lower) {
return true;
}
if let Some(ref tool_use) = node.tool_use {
if tool_use.name.to_lowercase().contains(&query_lower) {
return true;
}
}
if let Some(ref message) = node.message {
let text = message.text_content();
if text.to_lowercase().contains(&query_lower) {
return true;
}
for block in message.content_blocks() {
if let crate::parser::models::ContentBlock::ToolUse { name, .. } = block
{
if name.to_lowercase().contains(&query_lower) {
return true;
}
}
}
}
if let Some(ref thinking) = node.thinking {
if thinking.to_lowercase().contains(&query_lower) {
return true;
}
}
if let Some(ref tool_result) = node.tool_result {
if let Some(ref content) = tool_result.content {
if content.to_lowercase().contains(&query_lower) {
return true;
}
}
}
false
})
.collect()
};
self.results = matching_nodes
.iter()
.take(100) .map(|(idx, node)| node_to_result_item(*idx, node))
.collect();
self.status = if self.results.len() == self.total_results {
format!("{} nodes", self.total_results)
} else {
format!("{}/{} nodes", self.results.len(), self.total_results)
};
if !self.results.is_empty() {
self.list_state.select(Some(0));
}
Ok(())
}
pub fn render(&mut self, f: &mut Frame, area: Rect) {
if !self.is_active {
return;
}
let modal_area = centered_rect(80, 70, area);
use ratatui::widgets::Clear;
let background = Block::default().style(Style::default().bg(Color::Rgb(40, 42, 54))); f.render_widget(Clear, modal_area);
f.render_widget(background, modal_area);
let padded_area = Rect {
x: modal_area.x + 1,
y: modal_area.y + 1,
width: modal_area.width.saturating_sub(2),
height: modal_area.height.saturating_sub(2),
};
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Length(1), Constraint::Min(10), Constraint::Length(1), Constraint::Length(8), Constraint::Length(1), ])
.split(padded_area);
self.render_input(f, chunks[0]);
self.render_results(f, chunks[2]);
self.render_preview(f, chunks[4]);
self.render_status(f, chunks[5]);
}
fn render_input(&self, f: &mut Frame, area: Rect) {
let context_name = match &self.context {
SearchContext::Global => "Search: All Sessions",
SearchContext::Project(p) => &format!("Search: Project {}", p),
SearchContext::Session(_) => "Search: Session Content",
};
let title = format!(" {} ", context_name);
let input_text = if self.query.is_empty() {
Span::styled(
"Type to search...",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
)
} else {
Span::styled(
&self.query,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(Line::from(vec![
Span::styled(
" > ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
input_text,
]))
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(input, area);
f.set_cursor_position((area.x + 4 + self.cursor_pos as u16, area.y + 1));
}
fn render_results(&mut self, f: &mut Frame, area: Rect) {
let items: Vec<ListItem> = self
.results
.iter()
.map(|result| {
let title_color = if result.title.contains("[TOOL]") {
Color::Yellow
} else if result.title.contains("[USER]") {
Color::Green
} else if result.title.contains("[ASSISTANT]") {
Color::Cyan
} else if result.title.contains("[THINKING]") {
Color::Magenta
} else if result.title.contains("[RESULT]") {
if result.title.contains("ERROR") {
Color::Red
} else {
Color::Blue
}
} else {
Color::White
};
let title_span = Span::styled(
&result.title,
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
);
let subtitle_span = Span::styled(
format!(" │ {}", result.subtitle),
Style::default().fg(Color::Gray),
);
ListItem::new(Line::from(vec![title_span, subtitle_span]))
})
.collect();
let title = if self.results.is_empty() {
" No Results ".to_string()
} else {
format!(" Results ({}/{}) ", self.results.len(), self.total_results)
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::White)),
)
.highlight_style(
Style::default()
.bg(Color::Rgb(40, 40, 60))
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(" ");
f.render_stateful_widget(list, area, &mut self.list_state);
}
fn render_preview(&self, f: &mut Frame, area: Rect) {
let preview_text = if let Some(selected) = self.list_state.selected() {
if let Some(result) = self.results.get(selected) {
&result.preview
} else {
"No preview available"
}
} else {
"No preview available"
};
let preview = Paragraph::new(preview_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Preview ")
.border_style(Style::default().fg(Color::White)),
)
.wrap(Wrap { trim: false })
.style(Style::default().fg(Color::White));
f.render_widget(preview, area);
}
fn render_status(&self, f: &mut Frame, area: Rect) {
let help_text = " Enter: Select | Up/Down: Navigate | Esc: Cancel ";
let status = Paragraph::new(Line::from(vec![
Span::styled(&self.status, Style::default().fg(Color::Cyan)),
Span::raw(
" ".repeat(
area.width
.saturating_sub(self.status.len() as u16 + help_text.len() as u16 + 3)
as usize,
),
),
Span::styled(help_text, Style::default().fg(Color::Gray)),
]))
.alignment(Alignment::Left)
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
f.render_widget(status, area);
}
}
#[derive(Debug, Clone)]
pub enum SearchAction {
None,
Cancel,
SelectSession(String),
SelectNode(String),
}
fn parse_query(q: &str) -> (String, bool, Option<String>) {
let q = q.trim();
if q.eq_ignore_ascii_case("errors") {
return (String::new(), true, None);
}
if let Some(tool) = q.strip_prefix('@') {
return (String::new(), false, Some(tool.to_string()));
}
(q.to_string(), false, None)
}
fn session_to_result_item(s: &SessionFile) -> SearchResultItem {
let short_id = &s.session_id[..8.min(s.session_id.len())];
let msg_preview = s.first_message.as_deref().unwrap_or("(no message)");
let msg_short: String = msg_preview.chars().take(55).collect();
let model = s.model.as_deref().unwrap_or("-");
let updated = format_time_ago(s.modified_at);
let age = format_time_ago(s.created_at);
SearchResultItem {
title: format!("{} {}", short_id, s.project_name),
subtitle: msg_short,
preview: format!(
"Project: {}\nMessage: {}\nModel: {}\nErrors: {}\nUpdated: {}\nAge: {}",
s.project_name,
msg_preview,
model,
s.error_count,
updated,
age,
),
id: s.session_id.clone(),
score: 1.0,
}
}
fn get_node_category(node: &ExecutionNode) -> String {
if node.tool_use.is_some() {
"tool".to_string()
} else if node.tool_result.is_some() {
"result".to_string()
} else if node.thinking.is_some() {
"thinking".to_string()
} else if node.message.is_some() {
match node.node_type {
crate::parser::models::NodeType::User => "user".to_string(),
crate::parser::models::NodeType::Assistant => "assistant".to_string(),
_ => node.node_type.to_string(),
}
} else {
node.node_type.to_string()
}
}
fn node_to_result_item(index: usize, node: &ExecutionNode) -> SearchResultItem {
let node_type = node.node_type;
let node_type_str = node_type.as_str();
let (title, subtitle) = if node.tool_use.is_some() {
let tool_name = node
.tool_use
.as_ref()
.map(|t| t.name.clone())
.unwrap_or_else(|| "Unknown".to_string());
let time = node
.timestamp
.map(format_timestamp)
.unwrap_or_else(|| "??:??:??".to_string());
(
format!("#{} [TOOL] {}", index + 1, tool_name),
format!("{} • tool use", time),
)
} else if node.tool_result.is_some() {
let status = node
.tool_result
.as_ref()
.and_then(|r| r.is_error)
.map(|is_err| if is_err { "ERROR" } else { "OK" })
.unwrap_or("RESULT");
let time = node
.timestamp
.map(format_timestamp)
.unwrap_or_else(|| "??:??:??".to_string());
(
format!("#{} [RESULT] {}", index + 1, status),
format!("{} • tool result", time),
)
} else if node.thinking.is_some() {
let preview = node
.thinking
.as_ref()
.map(|t| {
let preview: String = t.chars().take(60).collect();
if t.len() > 60 {
format!("{}...", preview)
} else {
preview
}
})
.unwrap_or_else(|| "Thinking...".to_string());
let time = node
.timestamp
.map(format_timestamp)
.unwrap_or_else(|| "??:??:??".to_string());
(
format!("#{} [THINKING] {}", index + 1, preview),
format!("{} • thinking", time),
)
} else if node.message.is_some() && node_type == crate::parser::models::NodeType::User {
let preview = node
.message
.as_ref()
.map(|m| {
let text = m.text_content();
if text.is_empty() {
"User message".to_string()
} else {
let p: String = text.chars().take(60).collect();
if text.len() > 60 {
format!("{}...", p)
} else {
p
}
}
})
.unwrap_or_else(|| "User message".to_string());
let time = node
.timestamp
.map(format_timestamp)
.unwrap_or_else(|| "??:??:??".to_string());
(
format!("#{} [USER] {}", index + 1, preview),
format!("{} • user message", time),
)
} else if node.message.is_some() && node_type == crate::parser::models::NodeType::Assistant {
let time = node
.timestamp
.map(format_timestamp)
.unwrap_or_else(|| "??:??:??".to_string());
(
format!("#{} [ASSISTANT]", index + 1),
format!("{} • assistant response", time),
)
} else {
let time = node
.timestamp
.map(format_timestamp)
.unwrap_or_else(|| "??:??:??".to_string());
(
format!("#{} [{}]", index + 1, node_type_str.to_uppercase()),
format!("{} • {}", time, node_type_str),
)
};
let preview = build_node_preview(node);
let id = node.uuid.clone().unwrap_or_else(|| index.to_string());
SearchResultItem {
title,
subtitle,
preview,
id,
score: 1.0,
}
}
fn build_node_preview(node: &ExecutionNode) -> String {
let mut preview = String::new();
let category = get_node_category(node);
preview.push_str(&format!("Type: {}\n", category));
if let Some(ref uuid) = node.uuid {
preview.push_str(&format!("UUID: {}\n", uuid));
}
if let Some(ref tool_use) = node.tool_use {
preview.push_str(&format!("Tool: {}\n", tool_use.name));
preview.push_str(&format!(
"Input: {}\n",
serde_json::to_string_pretty(&tool_use.input).unwrap_or_default()
));
}
if let Some(ref message) = node.message {
let text = message.text_content();
if !text.is_empty() {
preview.push_str(&format!("Content:\n{}\n", truncate_string(&text, 500)));
}
for block in message.content_blocks() {
if let crate::parser::models::ContentBlock::ToolUse { name, input, .. } = block {
preview.push_str(&format!(
"Tool: {}\nInput: {}\n",
name,
serde_json::to_string_pretty(input).unwrap_or_default()
));
}
}
}
if let Some(ref thinking) = node.thinking {
let preview_text = truncate_string(thinking, 500);
preview.push_str(&format!("Thinking:\n{}\n", preview_text));
}
if let Some(ref tool_result) = node.tool_result {
if let Some(is_error) = tool_result.is_error {
preview.push_str(&format!(
"Status: {}\n",
if is_error { "ERROR" } else { "OK" }
));
}
if let Some(ref content) = tool_result.content {
let preview_text = truncate_string(content, 300);
preview.push_str(&format!("Result:\n{}\n", preview_text));
}
}
preview
}
fn truncate_string(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars).collect();
format!("{}...", truncated)
}
}
fn format_timestamp(ts_ms: i64) -> String {
let ts_s = ts_ms / 1000;
let dt = chrono::Local.timestamp_opt(ts_s, 0);
match dt.single() {
Some(datetime) => datetime.format("%H:%M:%S").to_string(),
None => "??:??:??".to_string(),
}
}
fn format_time_ago(timestamp: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let diff = now - timestamp;
if diff < 60 {
"just now".to_string()
} else if diff < 3600 {
format!("{}m ago", diff / 60)
} else if diff < 86400 {
format!("{}h ago", diff / 3600)
} else if diff < 604800 {
format!("{}d ago", diff / 86400)
} else {
format!("{}w ago", diff / 604800)
}
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let popup_layout = Layout::vertical([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::horizontal([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}