diff --git a/src/local_api_docs_search/cli/interactive.py b/src/local_api_docs_search/cli/interactive.py new file mode 100644 index 0000000..a81387f --- /dev/null +++ b/src/local_api_docs_search/cli/interactive.py @@ -0,0 +1,212 @@ +"""Interactive search mode with Rich-powered UI.""" + +from typing import List, Optional + +from rich.console import Console +from rich.prompt import Prompt +from rich.text import Text +from rich.panel import Panel +from rich import box + +from local_api_docs_search.models.document import SearchResult +from local_api_docs_search.search.searcher import Searcher +from local_api_docs_search.utils.formatters import get_source_style + +console = Console() + + +class InteractiveSession: + """Interactive search session with history and navigation.""" + + def __init__(self): + """Initialize the interactive session.""" + self._searcher = Searcher() + self._history: List[str] = [] + self._history_index: int = -1 + self._results: List[SearchResult] = [] + self._result_index: int = 0 + self._current_query: str = "" + + def run(self): + """Run the interactive session.""" + self._print_welcome() + + while True: + try: + query = self._get_input() + + if query is None: + break + + if not query.strip(): + continue + + self._history.append(query) + self._history_index = len(self._history) + + self._execute_search(query) + + except KeyboardInterrupt: + console.print("\n[italic]Use 'exit' or 'quit' to leave[/]") + except EOFError: + break + + console.print("\n[italic]Goodbye![/]") + + def _print_welcome(self): + """Print welcome message.""" + welcome_text = Text.assemble( + ("Local API Docs Search\n", "bold cyan"), + ("-" * 40, "dim\n"), + ("Type your query and press Enter to search.\n", "white"), + ("Commands:\n", "bold yellow"), + (" :q, quit, exit - Leave interactive mode\n", "dim"), + (" :h, help - Show this help\n", "dim"), + (" :c, clear - Clear search results\n", "dim"), + (" :n, next - Next result\n", "dim"), + (" :p, prev - Previous result\n", "dim"), + (" ↑/↓ - History navigation\n", "dim"), + ) + + panel = Panel(welcome_text, title="Welcome", expand=False) + console.print(panel) + + def _get_input(self) -> Optional[str]: + """Get user input with history navigation.""" + prompt = Prompt.ask( + "[bold cyan]Search[/]", + default="", + show_default=False, + accept_default=False, + ) + + if prompt in (":q", ":quit", "quit", "exit", "exit()"): + return None + + if prompt in (":h", ":help", "help"): + self._print_welcome() + return "" + + if prompt in (":c", ":clear", "clear"): + self._results = [] + console.print("[italic]Results cleared[/]") + return "" + + if prompt in (":n", ":next", "next"): + self._navigate_results(1) + return "" + + if prompt in (":p", ":prev", "previous"): + self._navigate_results(-1) + return "" + + return prompt + + def _execute_search(self, query: str): + """Execute search and display results.""" + self._current_query = query + self._result_index = 0 + + with console.status("Searching..."): + self._results = self._searcher.hybrid_search(query, limit=10) + + if not self._results: + console.print("[italic]No results found[/]\n") + return + + console.print(f"\n[bold]Found {len(self._results)} result(s)[/]\n") + self._display_current_result() + + def _display_current_result(self): + """Display the current result.""" + if not self._results: + return + + result = self._results[self._result_index] + + source_style = get_source_style(result.document.source_type) + + content = Text() + content.append(f"Result {self._result_index + 1}/{len(self._results)}\n", "bold yellow") + content.append(f"Title: {result.document.title}\n", "bold") + content.append(f"Type: {result.document.source_type.value}\n", source_style) + content.append(f"Score: {result.score:.4f}\n\n", "dim") + + preview = result.document.content[:500] + if len(result.document.content) > 500: + preview += "..." + content.append(preview) + + if result.document.file_path: + content.append(f"\n\n[dim]File: {result.document.file_path}[/]") + + panel = Panel( + content, + title=f"Result {self._result_index + 1}", + expand=False, + box=box.ROUNDED, + ) + + console.print(panel) + + if result.highlights: + console.print("\n[bold]Highlights:[/]") + for highlight in result.highlights[:3]: + console.print(f" [dim]{highlight}[/]") + + console.print() + + def _navigate_results(self, direction: int): + """Navigate through search results.""" + if not self._results: + console.print("[italic]No results to navigate[/]") + return + + new_index = self._result_index + direction + + if new_index < 0: + new_index = 0 + elif new_index >= len(self._results): + new_index = len(self._results) - 1 + + self._result_index = new_index + self._display_current_result() + + +def run_interactive(): + """Run the interactive search mode.""" + session = InteractiveSession() + session.run() + + +class InteractiveSearch: + """Legacy interactive search class for compatibility.""" + + def __init__(self): + """Initialize the interactive search.""" + self._searcher = Searcher() + self._history: List[str] = [] + + def search(self, query: str) -> List[SearchResult]: + """Execute search. + + Args: + query: Search query + + Returns: + List of search results + """ + self._history.append(query) + return self._searcher.hybrid_search(query) + + def get_history(self) -> List[str]: + """Get search history. + + Returns: + List of past queries + """ + return self._history + + def clear_history(self): + """Clear search history.""" + self._history = []