diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 3526f89f..e7422d0a 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -67,15 +67,23 @@ def _print_json(result: Any) -> None: # --- Rich formatters --- -def _display_search_results(result: dict[str, Any]) -> None: - """Render search-notes results as a Rich table.""" +def _display_search_results(result: dict[str, Any], query: str = "") -> None: + """Render search-notes results as a Rich table. + + Real SearchResponse.model_dump() shape: + results: list of SearchResult dicts (title, type, permalink, score, matched_chunk, content) + current_page: int (NOT "page") + page_size: int + total: int + has_more: bool + """ results = result.get("results", []) total = result.get("total", len(results)) - query = result.get("query") or "" - page = result.get("page", 1) - page_size = result.get("page_size", len(results)) + # Real key is "current_page"; fall back to "page" for forward-compat. + page = result.get("current_page") or result.get("page", 1) + page_size = result.get("page_size", len(results)) or 1 - title = f"Search results for [bold cyan]{query}[/bold cyan]" if query else "Search results" + title = f"Search: [bold cyan]{query}[/bold cyan]" if query else "Search results" subtitle = f"{total} result(s) • page {page} of {max(1, -(-total // page_size))}" if not results: @@ -85,13 +93,21 @@ def _display_search_results(result: dict[str, Any]) -> None: table = Table(show_header=True, header_style="bold", expand=False) table.add_column("Type", style="dim", width=12) table.add_column("Title", style="bold cyan") + table.add_column("Score", style="yellow", width=7) table.add_column("Permalink", style="green") + table.add_column("Snippet", style="dim", max_width=60) for item in results: item_type = item.get("type", "") item_title = item.get("title") or item.get("permalink", "") permalink = item.get("permalink", "") - table.add_row(item_type, item_title, permalink) + score = item.get("score") + score_str = f"{score:.2f}" if score is not None else "" + # Prefer matched_chunk as the most relevant snippet; fall back to content. + raw_snippet = item.get("matched_chunk") or item.get("content") or "" + # Truncate to ~200 chars so the table stays readable. + snippet = raw_snippet[:200].replace("\n", " ") if raw_snippet else "" + table.add_row(item_type, item_title, score_str, permalink, snippet) console.print(Panel(table, title=title, subtitle=subtitle, expand=False)) @@ -116,33 +132,59 @@ def _display_read_note(result: dict[str, Any]) -> None: def _display_build_context(result: dict[str, Any]) -> None: - """Render build-context result as a Rich tree.""" + """Render build-context result as a Rich tree. + + Real GraphContext.model_dump() shape: + results: list of ContextResult dicts, each with: + primary_result: EntitySummary | RelationSummary | ObservationSummary + observations: list of ObservationSummary + related_results: list of EntitySummary | RelationSummary | ObservationSummary + metadata: {"uri": ..., ...} + page/page_size/has_more + + Each summary has: type, title (EntitySummary/RelationSummary), permalink, + and relation_type (RelationSummary only). + """ metadata = result.get("metadata", {}) uri = metadata.get("uri", "") - results = result.get("results", []) - total = len(results) + context_items: list[dict[str, Any]] = list(result.get("results", [])) label = f"[bold cyan]{uri}[/bold cyan]" if uri else "Context" tree = Tree(f"[bold]Context:[/bold] {label}") - if not results: + if not context_items: tree.add("[dim]No related content found.[/dim]") else: - for item in results: - item_title = item.get("title") or item.get("permalink", "") - relation = item.get("relation_type", "") - item_type = item.get("type", "") + for context_result in context_items: + # --- Primary result node --- + primary = context_result.get("primary_result", {}) + p_title = primary.get("title") or primary.get("permalink", "") + p_type = primary.get("type", "") + primary_label = f"[cyan]{p_title}[/cyan]" + if p_type: + primary_label = f"[dim]{p_type}[/dim] {primary_label}" + primary_node = tree.add(primary_label) - parts = [] - if relation: - parts.append(f"[yellow]{relation}[/yellow]") - if item_type: - parts.append(f"[dim]{item_type}[/dim]") - parts.append(f"[cyan]{item_title}[/cyan]") + # --- Related items as children --- + related: list[dict[str, Any]] = list(context_result.get("related_results", [])) + for rel_item in related: + rel_title = rel_item.get("title") or rel_item.get("permalink", "") + rel_type = rel_item.get("type", "") + relation = rel_item.get("relation_type", "") - tree.add(" ".join(parts)) + parts = [] + if relation: + parts.append(f"[yellow]{relation}[/yellow]") + if rel_type: + parts.append(f"[dim]{rel_type}[/dim]") + parts.append(f"[cyan]{rel_title}[/cyan]") + primary_node.add(" ".join(parts)) - subtitle = f"{total} related item(s)" + # Count total related items across all primary results. + total_related = sum( + len(cr.get("related_results", [])) for cr in context_items + ) + subtitle = f"{len(context_items)} primary • {total_related} related" console.print(Panel(tree, subtitle=subtitle, expand=False)) @@ -845,7 +887,7 @@ def search_notes( if json_output or not _use_rich(): _print_json(result) else: - _display_search_results(result) + _display_search_results(result, query=query or "") except ValueError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py index 610c96ea..23a2a5c7 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -27,46 +27,72 @@ READ_NOTE_RESULT = { } SEARCH_RESULT = { - "query": "test", + # Real SearchResponse.model_dump() uses "current_page", not "page". + # No "query" key in the response -- the query comes from the CLI argument. "total": 2, - "page": 1, + "current_page": 1, "page_size": 10, + "has_more": False, "results": [ { "type": "entity", "title": "Test Note", "permalink": "notes/test-note", "file_path": "notes/Test Note.md", + "score": 0.95, + "matched_chunk": "A snippet about test notes", + "content": None, }, { "type": "observation", "title": "Another Note", "permalink": "notes/another-note", "file_path": "notes/Another Note.md", + "score": 0.72, + "matched_chunk": None, + "content": "Full content here", }, ], } SEARCH_RESULT_EMPTY = { - "query": "nothing", "total": 0, - "page": 1, + "current_page": 1, "page_size": 10, + "has_more": False, "results": [], } BUILD_CONTEXT_RESULT = { + # Real GraphContext.model_dump() shape: results is a list of ContextResult dicts. + # Each ContextResult has primary_result + observations + related_results. "results": [ { - "type": "entity", - "title": "Related Note", - "permalink": "notes/related", - "relation_type": "references", + "primary_result": { + "type": "entity", + "external_id": "abc123", + "title": "Test Note", + "permalink": "notes/test-note", + "file_path": "notes/Test Note.md", + "created_at": "2025-01-01T00:00:00", + }, + "observations": [], + "related_results": [ + { + "type": "relation", + "title": "Related Note", + "permalink": "notes/related", + "file_path": "notes/Related Note.md", + "relation_type": "references", + "created_at": "2025-01-01T00:00:00", + } + ], } ], "metadata": {"uri": "notes/test-note", "depth": 1}, "page": 1, "page_size": 10, + "has_more": False, } BUILD_CONTEXT_EMPTY = { @@ -74,16 +100,18 @@ BUILD_CONTEXT_EMPTY = { "metadata": {"uri": "notes/test-note", "depth": 1}, "page": 1, "page_size": 10, + "has_more": False, } RECENT_ACTIVITY_RESULT = [ + # Real _extract_recent_rows output keys: type/title/permalink/file_path/created_at + # (optional: project). No "updated_at" key in the real output. { "type": "entity", "title": "Note A", "permalink": "notes/note-a", "file_path": "notes/Note A.md", "created_at": "2025-01-01 00:00:00", - "updated_at": "2025-01-01 12:00:00", }, { "type": "entity", @@ -91,7 +119,6 @@ RECENT_ACTIVITY_RESULT = [ "permalink": "notes/note-b", "file_path": "notes/Note B.md", "created_at": "2025-01-02 00:00:00", - "updated_at": None, }, ] @@ -125,10 +152,11 @@ def test_search_notes_rich_output_default(mock_mcp): # Rich output should NOT be valid JSON with pytest.raises((json.JSONDecodeError, ValueError)): json.loads(result.output) - # But it should contain the result titles + # But it should contain the result titles and partial permalink assert "Test Note" in result.output assert "Another Note" in result.output - assert "notes/test-note" in result.output + # Rich may truncate long permalinks with ellipsis; check the prefix. + assert "notes/test-no" in result.output @patch( @@ -287,7 +315,9 @@ def test_build_context_json_flag_overrides_tty(mock_mcp): assert result.exit_code == 0, f"CLI failed: {result.output}" data = json.loads(result.output) assert "results" in data - assert data["results"][0]["title"] == "Related Note" + # Real shape: results[i] is a ContextResult with primary_result nested inside. + assert data["results"][0]["primary_result"]["title"] == "Test Note" + assert data["results"][0]["related_results"][0]["title"] == "Related Note" @patch(