use crate::api::{ApiClient, RecallRequest, ReflectRequest}; use anyhow::Result; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use hindsight_client::types::{BankListItem, RecallResult, EntityListItem, Budget}; use serde_json::{Map, Value}; use ratatui::{ backend::{Backend, CrosstermBackend}, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, Terminal, }; use std::io; use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::thread; use std::time::{Duration, Instant}; // Brand gradient colors: #0074d9 -> #009296 const BRAND_START: Color = Color::Rgb(0, 116, 217); // #0074d9 const BRAND_END: Color = Color::Rgb(0, 146, 150); // #009296 const BRAND_MID: Color = Color::Rgb(0, 131, 183); // Midpoint /// Main view types (like k9s contexts) #[derive(Debug, Clone, PartialEq)] enum View { Banks, Memories(String), // bank_id Entities(String), // bank_id Documents(String), // bank_id Query(String), // bank_id - combines recall and reflect } impl View { fn title(&self) -> &str { match self { View::Banks => "Banks", View::Memories(_) => "Memories", View::Entities(_) => "Entities", View::Documents(_) => "Documents", View::Query(_) => "Query", } } fn bank_id(&self) -> Option<&str> { match self { View::Banks => None, View::Memories(id) | View::Entities(id) | View::Documents(id) | View::Query(id) => Some(id), } } } /// Query mode for the Query view #[derive(Debug, Clone, PartialEq)] enum QueryMode { Recall, Reflect, } /// Input mode for recall/reflect queries #[derive(Debug, Clone, PartialEq)] enum InputMode { Normal, Query, } /// Query result from background thread enum QueryResult { Recall(Result, String>), Reflect(Result), } /// Application state struct App { client: ApiClient, view: View, view_history: Vec, // List states banks: Vec, banks_state: ListState, selected_bank_id: Option, memories: Vec>, memories_state: ListState, viewing_memory: Option>, memories_limit: i64, memories_offset: i64, horizontal_scroll: usize, entities: Vec, entities_state: ListState, viewing_entity: Option, documents: Vec>, documents_state: ListState, viewing_document: Option>, // Query state (unified recall/reflect) query_mode: QueryMode, query_text: String, query_budget: Budget, query_max_tokens: i64, query_results: Vec, query_results_state: ListState, query_response: String, viewing_recall_result: Option, // Input mode input_mode: InputMode, // Status messages status_message: String, error_message: String, // Help visibility show_help: bool, // Loading state loading: bool, // Auto-refresh auto_refresh_enabled: bool, last_refresh: Instant, refresh_interval: Duration, // Background query receiver query_receiver: Option>, } impl App { fn new(client: ApiClient) -> Self { let mut app = Self { client, view: View::Banks, view_history: Vec::new(), banks: Vec::new(), banks_state: ListState::default(), selected_bank_id: None, memories: Vec::new(), memories_state: ListState::default(), viewing_memory: None, memories_limit: 500, memories_offset: 0, horizontal_scroll: 0, entities: Vec::new(), entities_state: ListState::default(), viewing_entity: None, documents: Vec::new(), documents_state: ListState::default(), viewing_document: None, query_mode: QueryMode::Recall, query_text: String::new(), query_budget: Budget::Mid, query_max_tokens: 4096, query_results: Vec::new(), query_results_state: ListState::default(), query_response: String::new(), viewing_recall_result: None, input_mode: InputMode::Normal, status_message: String::from("Select a bank to start. Press ? for help"), error_message: String::new(), show_help: false, loading: false, auto_refresh_enabled: true, last_refresh: Instant::now(), refresh_interval: Duration::from_secs(5), query_receiver: None, }; // Select first item by default app.banks_state.select(Some(0)); app.memories_state.select(Some(0)); app.entities_state.select(Some(0)); app.documents_state.select(Some(0)); app.query_results_state.select(Some(0)); app } fn refresh(&mut self) -> Result<()> { self.loading = true; self.error_message.clear(); let result = match self.view.clone() { View::Banks => self.load_banks(), View::Memories(bank_id) => self.load_memories(&bank_id), View::Entities(bank_id) => self.load_entities(&bank_id), View::Documents(bank_id) => self.load_documents(&bank_id), View::Query(_) => Ok(()), // Query is query-driven }; self.loading = false; if let Err(e) = result { self.error_message = format!("Error: {}", e); } Ok(()) } fn toggle_auto_refresh(&mut self) { self.auto_refresh_enabled = !self.auto_refresh_enabled; if self.auto_refresh_enabled { self.status_message = "Auto-refresh enabled (5s)".to_string(); self.last_refresh = Instant::now(); } else { self.status_message = "Auto-refresh disabled".to_string(); } } fn should_refresh(&self) -> bool { self.auto_refresh_enabled && self.last_refresh.elapsed() >= self.refresh_interval } fn do_auto_refresh(&mut self) -> Result<()> { if self.should_refresh() { self.last_refresh = Instant::now(); self.refresh()?; } Ok(()) } fn load_banks(&mut self) -> Result<()> { self.banks = self.client.list_agents(false)?; if !self.banks.is_empty() && self.banks_state.selected().is_none() { self.banks_state.select(Some(0)); } self.status_message = format!("Loaded {} banks", self.banks.len()); Ok(()) } fn load_memories(&mut self, bank_id: &str) -> Result<()> { let response = self.client.list_memories( bank_id, None, None, Some(self.memories_limit), Some(self.memories_offset), false )?; self.memories = response.items; if !self.memories.is_empty() && self.memories_state.selected().is_none() { self.memories_state.select(Some(0)); } self.status_message = format!("Loaded {} memories (limit: {}, offset: {})", self.memories.len(), self.memories_limit, self.memories_offset); Ok(()) } fn load_more_memories(&mut self) -> Result<()> { if let View::Memories(bank_id) = &self.view { let bank_id = bank_id.clone(); self.memories_offset += self.memories_limit; self.load_memories(&bank_id)?; } Ok(()) } fn load_prev_memories(&mut self) -> Result<()> { if let View::Memories(bank_id) = &self.view { let bank_id = bank_id.clone(); self.memories_offset = (self.memories_offset - self.memories_limit).max(0); self.load_memories(&bank_id)?; } Ok(()) } fn load_entities(&mut self, bank_id: &str) -> Result<()> { let response = self.client.list_entities(bank_id, Some(100), false)?; self.entities = response.items; if !self.entities.is_empty() && self.entities_state.selected().is_none() { self.entities_state.select(Some(0)); } self.status_message = format!("Loaded {} entities", self.entities.len()); Ok(()) } fn load_documents(&mut self, bank_id: &str) -> Result<()> { let response = self.client.list_documents(bank_id, None, Some(100), Some(0), false)?; self.documents = response.items; if !self.documents.is_empty() && self.documents_state.selected().is_none() { self.documents_state.select(Some(0)); } self.status_message = format!("Loaded {} documents", self.documents.len()); Ok(()) } fn execute_query(&mut self) { if let View::Query(bank_id) = &self.view { if self.query_text.is_empty() { self.error_message = "Query cannot be empty".to_string(); return; } self.loading = true; self.error_message.clear(); self.input_mode = InputMode::Normal; // Create channel for receiving results let (tx, rx) = mpsc::channel(); self.query_receiver = Some(rx); // Clone data for the thread let client = self.client.clone(); let bank_id = bank_id.clone(); let query_mode = self.query_mode.clone(); let query_text = self.query_text.clone(); let query_budget = self.query_budget.clone(); let query_max_tokens = self.query_max_tokens; // Spawn background thread thread::spawn(move || { match query_mode { QueryMode::Recall => { let request = RecallRequest { query: query_text, types: None, budget: Some(query_budget), max_tokens: query_max_tokens, trace: false, query_timestamp: None, include: None, }; let result = client.recall(&bank_id, &request, false) .map(|r| r.results) .map_err(|e| e.to_string()); let _ = tx.send(QueryResult::Recall(result)); } QueryMode::Reflect => { let request = ReflectRequest { query: query_text, budget: Some(query_budget), context: None, include: None, }; let result = client.reflect(&bank_id, &request, false) .map(|r| r.text) .map_err(|e| e.to_string()); let _ = tx.send(QueryResult::Reflect(result)); } } }); } } fn check_query_result(&mut self) { if let Some(receiver) = &self.query_receiver { match receiver.try_recv() { Ok(QueryResult::Recall(Ok(results))) => { self.query_results = results; if !self.query_results.is_empty() { self.query_results_state.select(Some(0)); } self.loading = false; self.status_message = format!("Found {} results", self.query_results.len()); self.query_receiver = None; } Ok(QueryResult::Recall(Err(e))) => { self.error_message = format!("Recall failed: {}", e); self.loading = false; self.query_receiver = None; } Ok(QueryResult::Reflect(Ok(text))) => { self.query_response = text; self.loading = false; self.status_message = "Reflection complete".to_string(); self.query_receiver = None; } Ok(QueryResult::Reflect(Err(e))) => { self.error_message = format!("Reflect failed: {}", e); self.loading = false; self.query_receiver = None; } Err(TryRecvError::Empty) => { // Still waiting for result } Err(TryRecvError::Disconnected) => { self.error_message = "Query thread disconnected".to_string(); self.loading = false; self.query_receiver = None; } } } } fn toggle_query_mode(&mut self) { self.query_mode = match self.query_mode { QueryMode::Recall => QueryMode::Reflect, QueryMode::Reflect => QueryMode::Recall, }; self.status_message = format!("Switched to {} mode", match self.query_mode { QueryMode::Recall => "Recall", QueryMode::Reflect => "Reflect", }); } fn cycle_budget(&mut self) { self.query_budget = match self.query_budget { Budget::Low => Budget::Mid, Budget::Mid => Budget::High, Budget::High => Budget::Low, }; self.status_message = format!("Budget: {:?}", self.query_budget); } fn adjust_max_tokens(&mut self, increase: bool) { if increase { self.query_max_tokens = (self.query_max_tokens + 1024).min(16384); } else { self.query_max_tokens = (self.query_max_tokens - 1024).max(512); } self.status_message = format!("Max tokens: {}", self.query_max_tokens); } fn scroll_left(&mut self) { self.horizontal_scroll = self.horizontal_scroll.saturating_sub(10); } fn scroll_right(&mut self) { self.horizontal_scroll = (self.horizontal_scroll + 10).min(200); } fn reset_horizontal_scroll(&mut self) { self.horizontal_scroll = 0; } fn next_item(&mut self) { match &self.view { View::Banks => { let i = match self.banks_state.selected() { Some(i) => { if i >= self.banks.len().saturating_sub(1) { 0 } else { i + 1 } } None => 0, }; self.banks_state.select(Some(i)); } View::Memories(_) => { let i = match self.memories_state.selected() { Some(i) => { if i >= self.memories.len().saturating_sub(1) { 0 } else { i + 1 } } None => 0, }; self.memories_state.select(Some(i)); } View::Entities(_) => { let i = match self.entities_state.selected() { Some(i) => { if i >= self.entities.len().saturating_sub(1) { 0 } else { i + 1 } } None => 0, }; self.entities_state.select(Some(i)); } View::Documents(_) => { let i = match self.documents_state.selected() { Some(i) => { if i >= self.documents.len().saturating_sub(1) { 0 } else { i + 1 } } None => 0, }; self.documents_state.select(Some(i)); } View::Query(_) => { if self.query_mode == QueryMode::Recall { let i = match self.query_results_state.selected() { Some(i) => { if i >= self.query_results.len().saturating_sub(1) { 0 } else { i + 1 } } None => 0, }; self.query_results_state.select(Some(i)); } } } } fn previous_item(&mut self) { match &self.view { View::Banks => { let i = match self.banks_state.selected() { Some(i) => { if i == 0 { self.banks.len().saturating_sub(1) } else { i - 1 } } None => 0, }; self.banks_state.select(Some(i)); } View::Memories(_) => { let i = match self.memories_state.selected() { Some(i) => { if i == 0 { self.memories.len().saturating_sub(1) } else { i - 1 } } None => 0, }; self.memories_state.select(Some(i)); } View::Entities(_) => { let i = match self.entities_state.selected() { Some(i) => { if i == 0 { self.entities.len().saturating_sub(1) } else { i - 1 } } None => 0, }; self.entities_state.select(Some(i)); } View::Documents(_) => { let i = match self.documents_state.selected() { Some(i) => { if i == 0 { self.documents.len().saturating_sub(1) } else { i - 1 } } None => 0, }; self.documents_state.select(Some(i)); } View::Query(_) => { if self.query_mode == QueryMode::Recall { let i = match self.query_results_state.selected() { Some(i) => { if i == 0 { self.query_results.len().saturating_sub(1) } else { i - 1 } } None => 0, }; self.query_results_state.select(Some(i)); } } } } fn enter_view(&mut self) -> Result<()> { match &self.view { View::Banks => { if let Some(i) = self.banks_state.selected() { if let Some(bank) = self.banks.get(i) { let bank_id = bank.bank_id.clone(); self.selected_bank_id = Some(bank_id.clone()); self.view_history.push(self.view.clone()); self.view = View::Memories(bank_id.clone()); self.load_memories(&bank_id)?; } } } View::Memories(_) => { if let Some(i) = self.memories_state.selected() { if let Some(memory) = self.memories.get(i) { self.viewing_memory = Some(memory.clone()); self.status_message = "Viewing memory details (Esc to close)".to_string(); } } } View::Entities(_) => { if let Some(i) = self.entities_state.selected() { if let Some(entity) = self.entities.get(i).cloned() { self.viewing_entity = Some(entity); self.status_message = "Viewing entity details (Esc to close)".to_string(); } } } View::Documents(bank_id) => { if let Some(i) = self.documents_state.selected() { if let Some(doc) = self.documents.get(i) { // Fetch full document content let doc_id = doc.get("id") .and_then(|v| v.as_str()) .unwrap_or(""); if !doc_id.is_empty() { match self.client.get_document(bank_id, doc_id, false) { Ok(full_doc) => { // Convert to Map for display let doc_map: Map = serde_json::from_value( serde_json::to_value(full_doc)? )?; self.viewing_document = Some(doc_map); self.status_message = format!("Viewing document: {}", doc_id); } Err(e) => { self.error_message = format!("Failed to load document: {}", e); } } } } } } View::Query(_) => { // View recall result details if in recall mode if self.query_mode == QueryMode::Recall { if let Some(i) = self.query_results_state.selected() { if let Some(result) = self.query_results.get(i).cloned() { self.viewing_recall_result = Some(result); self.status_message = "Viewing recall result (Esc to close)".to_string(); } } } } } Ok(()) } fn go_back(&mut self) { // If viewing a detail view, close it first if self.viewing_memory.is_some() { self.viewing_memory = None; self.status_message = "Closed memory view".to_string(); return; } if self.viewing_entity.is_some() { self.viewing_entity = None; self.status_message = "Closed entity view".to_string(); return; } if self.viewing_document.is_some() { self.viewing_document = None; self.status_message = "Closed document view".to_string(); return; } if self.viewing_recall_result.is_some() { self.viewing_recall_result = None; self.status_message = "Closed recall result view".to_string(); return; } // Otherwise go back to previous view if let Some(prev_view) = self.view_history.pop() { self.view = prev_view; let _ = self.refresh(); } } fn switch_to_view(&mut self, new_view: View) -> Result<()> { if self.view != new_view { self.view_history.push(self.view.clone()); self.view = new_view; self.refresh()?; } Ok(()) } fn delete_selected_document(&mut self) -> Result<()> { if let View::Documents(bank_id) = &self.view { if let Some(i) = self.documents_state.selected() { if let Some(doc) = self.documents.get(i) { let doc_id = doc.get("id") .and_then(|v| v.as_str()) .unwrap_or(""); if !doc_id.is_empty() { match self.client.delete_document(bank_id, doc_id, false) { Ok(_) => { self.status_message = format!("Deleted document: {}", doc_id); self.refresh()?; } Err(e) => { self.error_message = format!("Failed to delete document: {}", e); } } } } } } Ok(()) } } fn ui(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(5), // Shortcuts bar (context + shortcuts, max 3 rows + 2 border) Constraint::Length(3), // Header Constraint::Min(0), // Main content Constraint::Length(1), // Footer/status only (no border) ]) .split(f.area()); // Control bar render_control_bar(f, app, chunks[0]); // Header render_header(f, app, chunks[1]); // Main content if app.show_help { render_help(f, chunks[2]); } else { match &app.view { View::Banks => render_banks(f, app, chunks[2]), View::Memories(_) => render_memories(f, app, chunks[2]), View::Entities(_) => render_entities(f, app, chunks[2]), View::Documents(_) => render_documents(f, app, chunks[2]), View::Query(_) => render_query(f, app, chunks[2]), } } // Footer render_footer(f, app, chunks[3]); } fn render_control_bar(f: &mut Frame, app: &App, area: Rect) { // Build contextual shortcuts based on view and input mode let shortcuts = match (&app.view, &app.input_mode) { (View::Banks, InputMode::Normal) => vec![ ("Enter", "Select", BRAND_START), ("R", "Refresh", BRAND_MID), ("?", "Help", BRAND_END), ("q", "Quit", Color::Red), ], (View::Memories(_), InputMode::Normal) => vec![ ("Enter", "View", BRAND_START), ("/", "Query", BRAND_MID), ("←→", "Scroll", BRAND_START), ("n", "Next", BRAND_MID), ("p", "Prev", BRAND_MID), ("Esc", "Back", BRAND_END), ("R", "Refresh", BRAND_END), ("?", "Help", BRAND_END), ("q", "Quit", Color::Red), ], (View::Entities(_), InputMode::Normal) => vec![ ("Enter", "View", BRAND_START), ("/", "Query", BRAND_MID), ("←→", "Scroll", BRAND_START), ("Esc", "Back", BRAND_END), ("R", "Refresh", BRAND_END), ("?", "Help", BRAND_END), ("q", "Quit", Color::Red), ], (View::Documents(_), InputMode::Normal) => vec![ ("Enter", "View", BRAND_START), ("/", "Query", BRAND_MID), ("←→", "Scroll", BRAND_START), ("Del", "Delete", Color::Red), ("Esc", "Back", BRAND_END), ("R", "Refresh", BRAND_END), ("?", "Help", BRAND_END), ("q", "Quit", Color::Red), ], (View::Query(_), InputMode::Normal) => { let mut shortcuts = vec![ ("/", "Query", BRAND_MID), ("m", "Mode", BRAND_START), ]; if app.query_mode == QueryMode::Recall { shortcuts.push(("←→", "Scroll", BRAND_START)); } shortcuts.extend_from_slice(&[ ("b", "Budget", BRAND_END), ("+/-", "Tokens", BRAND_END), ("Esc", "Back", BRAND_END), ("?", "Help", BRAND_END), ("q", "Quit", Color::Red), ]); shortcuts }, (View::Query(_), InputMode::Query) => vec![ ("Enter", "Execute", BRAND_MID), ("Esc", "Cancel", Color::Red), ], _ => vec![ ("?", "Help", BRAND_END), ("q", "Quit", Color::Red), ], }; // Split into left (context) and right (shortcuts) sections let columns = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(30), // Context on left Constraint::Percentage(70), // Shortcuts on right ]) .split(area); // Left: Context info let context_info = match &app.view { View::Banks => "Context: Banks List".to_string(), View::Memories(bank_id) => format!("Context: Memories\nBank: {}", bank_id), View::Entities(bank_id) => format!("Context: Entities\nBank: {}", bank_id), View::Documents(bank_id) => format!("Context: Documents\nBank: {}", bank_id), View::Query(_bank_id) => { let mode = match app.query_mode { QueryMode::Recall => "Recall", QueryMode::Reflect => "Reflect", }; format!("Mode: {}\nBudget: {:?} | Tokens: {}", mode, app.query_budget, app.query_max_tokens) } }; let context_widget = Paragraph::new(context_info) .block(Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BRAND_START)) .title(" Context ")) .style(Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)) .alignment(Alignment::Left); f.render_widget(context_widget, columns[0]); // Right: Shortcuts in columns if many // Calculate shortcuts per column (max 3 lines of shortcuts) let max_shortcuts_per_col = 3; let num_cols = (shortcuts.len() + max_shortcuts_per_col - 1) / max_shortcuts_per_col; let mut shortcut_lines = vec![]; for row in 0..max_shortcuts_per_col { let mut line_spans = vec![]; for col in 0..num_cols { let idx = col * max_shortcuts_per_col + row; if idx < shortcuts.len() { let (key, desc, color) = &shortcuts[idx]; // Each shortcut with proper alignment let shortcut_text = format!("<{}> {:<10}", key, desc); line_spans.push(Span::styled( shortcut_text, Style::default().fg(*color).add_modifier(Modifier::BOLD) )); } } if !line_spans.is_empty() { shortcut_lines.push(Line::from(line_spans)); } } let shortcuts_widget = Paragraph::new(shortcut_lines) .block(Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BRAND_START)) .title(" Shortcuts ")) .alignment(Alignment::Left); f.render_widget(shortcuts_widget, columns[1]); } fn render_header(f: &mut Frame, app: &App, area: Rect) { let bank_info = if let Some(bank_id) = app.view.bank_id() { format!(" [{}]", bank_id) } else { String::new() }; let title = format!("Hindsight Explorer - {}{}", app.view.title(), bank_info); let header = Paragraph::new(title) .style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD)) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); f.render_widget(header, area); } fn render_footer(f: &mut Frame, app: &App, area: Rect) { // Simple status line only (shortcuts are now at the top, no border) let status_line = if !app.error_message.is_empty() { Line::from(vec![ Span::styled(" Error: ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), Span::raw(&app.error_message), ]) } else if app.loading { Line::from(Span::styled(" Loading...", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD))) } else if !app.status_message.is_empty() { Line::from(vec![ Span::raw(" "), Span::styled(&app.status_message, Style::default().fg(BRAND_MID)), ]) } else { Line::from("") }; let footer = Paragraph::new(status_line).alignment(Alignment::Left); f.render_widget(footer, area); } fn render_banks(f: &mut Frame, app: &mut App, area: Rect) { let items: Vec = app .banks .iter() .map(|bank| { let name = if bank.name.is_empty() { "Unnamed" } else { &bank.name }; let content = format!("{} - {}", bank.bank_id, name); ListItem::new(content).style(Style::default().fg(Color::White)) }) .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title("Banks")) .highlight_style( Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD), ) .highlight_symbol(">> "); f.render_stateful_widget(list, area, &mut app.banks_state); } fn render_memories(f: &mut Frame, app: &mut App, area: Rect) { // If viewing a memory, show its details if let Some(memory) = &app.viewing_memory { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(7), // Memory metadata Constraint::Min(0), // Full text content ]) .split(area); // Metadata section let mem_type = memory.get("fact_type") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let mentioned_at = memory.get("mentioned_at") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let occurred_start = memory.get("occurred_start") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let occurred_end = memory.get("occurred_end") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let metadata_text = format!( "Type: {}\nMentioned At: {}\nOccurred: {} to {}", mem_type, mentioned_at, occurred_start, occurred_end ); let metadata = Paragraph::new(metadata_text) .block(Block::default().borders(Borders::ALL).title("Memory Metadata")) .style(Style::default().fg(BRAND_START)); f.render_widget(metadata, chunks[0]); // Full text content let text = memory.get("text").and_then(|v| v.as_str()).unwrap_or("No text available"); let content_widget = Paragraph::new(text) .block(Block::default().borders(Borders::ALL).title("Full Text (Esc to close)")) .wrap(Wrap { trim: false }) .style(Style::default().fg(Color::White)); f.render_widget(content_widget, chunks[1]); } else { // Show memory list as table let mut items = vec![ // Header row ListItem::new(format!("{:<10} {:<18} {:<18} {}", "TYPE", "MENTIONED AT", "OCCURRED AT", "TEXT")) .style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD)) ]; // Data rows for memory in &app.memories { let mem_type = memory.get("fact_type") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let mentioned = memory.get("mentioned_at") .and_then(|v| v.as_str()) .and_then(|s| s.split('T').next()) .unwrap_or("-"); let occurred = memory.get("occurred_start") .and_then(|v| v.as_str()) .and_then(|s| s.split('T').next()) .unwrap_or("-"); let text = memory.get("text").and_then(|v| v.as_str()).unwrap_or(""); // Apply horizontal scroll let scrolled_text: String = text.chars().skip(app.horizontal_scroll).take(80).collect(); let content = format!("{:<10} {:<18} {:<18} {}", mem_type, mentioned, occurred, scrolled_text); items.push(ListItem::new(content).style(Style::default().fg(Color::White))); } let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(format!("Memories ({}) - Press Enter to view full text", app.memories.len()))) .highlight_style( Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD), ) .highlight_symbol(">> "); f.render_stateful_widget(list, area, &mut app.memories_state); } } fn render_entities(f: &mut Frame, app: &mut App, area: Rect) { // If viewing an entity, show its details if let Some(entity) = &app.viewing_entity { let entity_type = entity.metadata.as_ref() .and_then(|m| m.get("type")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); let metadata_text = format!( "Name: {}\nType: {}\nMentions: {}\nFirst Seen: {}\nLast Seen: {}", entity.canonical_name, entity_type, entity.mention_count, entity.first_seen.as_deref().unwrap_or("unknown"), entity.last_seen.as_deref().unwrap_or("unknown") ); let metadata = Paragraph::new(metadata_text) .block(Block::default().borders(Borders::ALL).title("Entity Details (Esc to close)")) .style(Style::default().fg(BRAND_START)) .wrap(Wrap { trim: false }); f.render_widget(metadata, area); } else { // Show entity list as table let mut items = vec![ // Header row ListItem::new(format!("{:<40} {:<15} {:<10}", "NAME", "TYPE", "MENTIONS")) .style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD)) ]; // Data rows for entity in &app.entities { let name = &entity.canonical_name; // Apply horizontal scroll to name let scrolled_name: String = name.chars().skip(app.horizontal_scroll).take(40).collect(); let entity_type = entity.metadata.as_ref() .and_then(|m| m.get("type")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); let mentions = entity.mention_count; let content = format!("{:<40} {:<15} {:<10}", scrolled_name, entity_type, mentions); items.push(ListItem::new(content).style(Style::default().fg(Color::White))); } let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(format!("Entities ({}) - Press Enter to view details", app.entities.len()))) .highlight_style( Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD), ) .highlight_symbol(">> "); f.render_stateful_widget(list, area, &mut app.entities_state); } } fn render_documents(f: &mut Frame, app: &mut App, area: Rect) { // If viewing a document, show its content if let Some(doc) = &app.viewing_document { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(5), // Document metadata Constraint::Min(0), // Content ]) .split(area); // Metadata section let doc_id = doc.get("id") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let content_type = doc.get("content_type") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let created_at = doc.get("created_at") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let metadata_text = format!( "ID: {}\nType: {}\nCreated: {}\n", doc_id, content_type, created_at ); let metadata = Paragraph::new(metadata_text) .block(Block::default().borders(Borders::ALL).title("Document Metadata")) .style(Style::default().fg(BRAND_START)); f.render_widget(metadata, chunks[0]); // Content section let content = doc.get("content") .and_then(|v| v.as_str()) .unwrap_or("No content available"); let content_widget = Paragraph::new(content) .block(Block::default().borders(Borders::ALL).title("Content (Esc to close)")) .wrap(Wrap { trim: false }) .style(Style::default().fg(Color::White)); f.render_widget(content_widget, chunks[1]); } else { // Show document list as table let mut items = vec![ // Header row ListItem::new(format!("{:<40} {:<20} {}", "ID", "TYPE", "CREATED")) .style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD)) ]; // Data rows for doc in &app.documents { let id = doc.get("id") .and_then(|v| v.as_str()) .unwrap_or("unknown"); // Apply horizontal scroll to id let scrolled_id: String = id.chars().skip(app.horizontal_scroll).take(40).collect(); let content_type = doc.get("content_type") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let created = doc.get("created_at") .and_then(|v| v.as_str()) .and_then(|s| s.split('T').next()) .unwrap_or("unknown"); let content = format!("{:<40} {:<20} {}", scrolled_id, content_type, created); items.push(ListItem::new(content).style(Style::default().fg(Color::White))); } let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(format!("Documents ({}) - Press Enter to view content", app.documents.len()))) .highlight_style( Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD), ) .highlight_symbol(">> "); f.render_stateful_widget(list, area, &mut app.documents_state); } } fn render_query(f: &mut Frame, app: &mut App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Query input Constraint::Min(0), // Results or Response ]) .split(area); // Query input let query_style = if app.input_mode == InputMode::Query { Style::default().fg(BRAND_END) } else { Style::default() }; let mode_label = match app.query_mode { QueryMode::Recall => "Recall", QueryMode::Reflect => "Reflect", }; let title = format!("{} Query (press / to edit, m to toggle mode)", mode_label); let query = Paragraph::new(app.query_text.as_str()) .style(query_style) .block(Block::default().borders(Borders::ALL).title(title)); f.render_widget(query, chunks[0]); // Show loading indicator if loading if app.loading { let loading_text = match app.query_mode { QueryMode::Recall => "Searching memories...", QueryMode::Reflect => "Reflecting on memories...", }; // Create animated dots based on time let dots = ".".repeat(((std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() / 500) % 4) as usize); let loading_lines = vec![ Line::from(""), Line::from(""), Line::from(vec![ Span::styled(" ", Style::default()), Span::styled(format!("{}{}", loading_text, dots), Style::default().fg(BRAND_MID).add_modifier(Modifier::BOLD)), ]), Line::from(""), Line::from(Span::styled(" Please wait while we process your query...", Style::default().fg(Color::DarkGray))), ]; let loading_widget = Paragraph::new(loading_lines) .block(Block::default().borders(Borders::ALL).title(format!("{} in progress", mode_label))) .alignment(Alignment::Left); f.render_widget(loading_widget, chunks[1]); return; } // Results or Response based on mode match app.query_mode { QueryMode::Recall => { // If viewing a recall result, show its details if let Some(result) = &app.viewing_recall_result { let recall_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(7), // Metadata Constraint::Min(0), // Full text ]) .split(chunks[1]); // Metadata section let mem_type = result.type_.as_deref().unwrap_or("unknown"); let occurred_start = result.occurred_start.as_deref().unwrap_or("unknown"); let occurred_end = result.occurred_end.as_deref().unwrap_or("unknown"); let mentioned_at = result.mentioned_at.as_deref().unwrap_or("unknown"); let metadata_text = format!( "Type: {}\nMentioned At: {}\nOccurred: {} to {}", mem_type, mentioned_at, occurred_start, occurred_end ); let metadata = Paragraph::new(metadata_text) .block(Block::default().borders(Borders::ALL).title("Recall Result Metadata")) .style(Style::default().fg(BRAND_START)); f.render_widget(metadata, recall_chunks[0]); // Full text content let content_widget = Paragraph::new(result.text.as_str()) .block(Block::default().borders(Borders::ALL).title("Full Text (Esc to close)")) .wrap(Wrap { trim: false }) .style(Style::default().fg(Color::White)); f.render_widget(content_widget, recall_chunks[1]); } else { // Show results as a table like memories let mut items = vec![ // Header row ListItem::new(format!("{:<10} {:<18} {:<18} {}", "TYPE", "OCCURRED START", "OCCURRED END", "TEXT")) .style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD)) ]; // Data rows for result in &app.query_results { let mem_type = result.type_.as_deref().unwrap_or("unknown"); let occurred_start = result.occurred_start.as_deref() .and_then(|s| s.split('T').next()) .unwrap_or("-"); let occurred_end = result.occurred_end.as_deref() .and_then(|s| s.split('T').next()) .unwrap_or("-"); let text = &result.text; // Apply horizontal scroll let scrolled_text: String = text.chars().skip(app.horizontal_scroll).take(80).collect(); let content = format!("{:<10} {:<18} {:<18} {}", mem_type, occurred_start, occurred_end, scrolled_text); items.push(ListItem::new(content).style(Style::default().fg(Color::White))); } let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(format!("Recall Results ({}) - Press Enter to view full text", app.query_results.len()))) .highlight_style( Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD), ) .highlight_symbol(">> "); f.render_stateful_widget(list, chunks[1], &mut app.query_results_state); } } QueryMode::Reflect => { let response_text = if app.query_response.is_empty() { "No response yet. Enter a query and press Enter to get a reflection." } else { app.query_response.as_str() }; let response = Paragraph::new(response_text) .style(Style::default().fg(Color::White)) .block(Block::default().borders(Borders::ALL).title("Reflect Response")) .wrap(Wrap { trim: false }); f.render_widget(response, chunks[1]); } } } fn render_help(f: &mut Frame, area: Rect) { let help_text = vec![ Line::from(Span::styled("Hindsight Explorer - Keyboard Shortcuts", Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))), Line::from(""), Line::from(vec![ Span::styled("Navigation Flow", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)), ]), Line::from(" 1. Start by selecting a bank (Enter)"), Line::from(" 2. View memories, entities, or documents for that bank"), Line::from(" 3. Press / from any view to query (recall/reflect)"), Line::from(""), Line::from(vec![ Span::styled("Basic Navigation", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)), ]), Line::from(" ↑/↓, j/k - Navigate up/down in lists"), Line::from(" ←/→, h/l - Scroll text left/right in tables"), Line::from(" Enter - Select item / view details"), Line::from(" Esc - Go back / close detail view"), Line::from(""), Line::from(vec![ Span::styled("Query View", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)), ]), Line::from(" / - Start or edit query (from any non-bank view)"), Line::from(" m - Toggle mode (Recall ↔ Reflect)"), Line::from(" b - Cycle budget (Low → Mid → High)"), Line::from(" +/- - Adjust max tokens"), Line::from(" Enter - Execute query"), Line::from(""), Line::from(vec![ Span::styled("General", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)), ]), Line::from(" R - Refresh current view"), Line::from(" ? - Toggle this help screen"), Line::from(" q - Quit"), Line::from(""), Line::from(Span::styled("Press ? to close help", Style::default().fg(Color::DarkGray))), ]; let help = Paragraph::new(help_text) .block(Block::default().borders(Borders::ALL).title("Help")) .alignment(Alignment::Left); f.render_widget(help, area); } fn run_app(terminal: &mut Terminal, mut app: App) -> Result<()> { // Initial load app.refresh()?; loop { terminal.draw(|f| ui(f, &mut app))?; if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { // Handle Ctrl+C to exit if key.code == KeyCode::Char('c') && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) { return Ok(()); } match app.input_mode { InputMode::Normal => { match key.code { KeyCode::Char('q') => return Ok(()), KeyCode::Char('?') => app.show_help = !app.show_help, // Navigation KeyCode::Down | KeyCode::Char('j') => app.next_item(), KeyCode::Up | KeyCode::Char('k') => app.previous_item(), KeyCode::Left | KeyCode::Char('h') => app.scroll_left(), KeyCode::Right | KeyCode::Char('l') => app.scroll_right(), KeyCode::Enter => { app.reset_horizontal_scroll(); app.enter_view()?; } KeyCode::Esc => { app.reset_horizontal_scroll(); app.go_back(); } // Refresh KeyCode::Char('R') => { app.refresh()?; } // Query input - start query from any non-bank view KeyCode::Char('/') => { match &app.view { View::Banks => { app.error_message = "Select a bank first".to_string(); } View::Query(_) => { app.input_mode = InputMode::Query; } _ => { // Switch to Query view using current bank if let Some(bank_id) = app.selected_bank_id.clone() { app.switch_to_view(View::Query(bank_id))?; app.input_mode = InputMode::Query; } else { app.error_message = "No bank selected".to_string(); } } } } // Query view controls KeyCode::Char('m') => { if matches!(app.view, View::Query(_)) { app.toggle_query_mode(); } } KeyCode::Char('b') => { if matches!(app.view, View::Query(_)) { app.cycle_budget(); } } KeyCode::Char('+') | KeyCode::Char('=') => { if matches!(app.view, View::Query(_)) { app.adjust_max_tokens(true); } } KeyCode::Char('-') => { if matches!(app.view, View::Query(_)) { app.adjust_max_tokens(false); } } // Delete document KeyCode::Delete => { if matches!(app.view, View::Documents(_)) { app.delete_selected_document()?; } } // Pagination for memories KeyCode::Char('n') => { if matches!(app.view, View::Memories(_)) { app.load_more_memories()?; } } KeyCode::Char('p') => { if matches!(app.view, View::Memories(_)) { app.load_prev_memories()?; } } _ => {} } } InputMode::Query => { match key.code { KeyCode::Enter => { if matches!(app.view, View::Query(_)) { app.execute_query(); } } KeyCode::Esc => { app.input_mode = InputMode::Normal; } KeyCode::Char(c) => { if matches!(app.view, View::Query(_)) { app.query_text.push(c); } } KeyCode::Backspace => { if matches!(app.view, View::Query(_)) { app.query_text.pop(); } } _ => {} } } } } } // Check for query results from background thread app.check_query_result(); // Auto-refresh check app.do_auto_refresh()?; } } pub fn run(client: &ApiClient) -> Result<()> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; // Create app and run it let app = App::new(client.clone()); let res = run_app(&mut terminal, app); // Restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; if let Err(err) = res { println!("Error: {:?}", err); } Ok(()) }