|
| 1 | +"""Formatting helpers for MCP tool outputs.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +from typing import Iterable, Sequence |
| 6 | + |
| 7 | +from basic_memory.schemas.search import SearchResponse, SearchResult |
| 8 | + |
| 9 | +ANSI_RESET = "\x1b[0m" |
| 10 | +ANSI_BOLD = "\x1b[1m" |
| 11 | +ANSI_DIM = "\x1b[2m" |
| 12 | +ANSI_CYAN = "\x1b[36m" |
| 13 | + |
| 14 | + |
| 15 | +def _apply_style(text: str, style: str, enabled: bool) -> str: |
| 16 | + if not enabled: |
| 17 | + return text |
| 18 | + return f"{style}{text}{ANSI_RESET}" |
| 19 | + |
| 20 | + |
| 21 | +def _strip_frontmatter(text: str) -> str: |
| 22 | + lines = text.splitlines() |
| 23 | + if not lines or lines[0].strip() != "---": |
| 24 | + return text |
| 25 | + |
| 26 | + for idx in range(1, len(lines)): |
| 27 | + if lines[idx].strip() == "---": |
| 28 | + return "\n".join(lines[idx + 1 :]).lstrip() |
| 29 | + return text |
| 30 | + |
| 31 | + |
| 32 | +def _parse_title(text: str) -> str | None: |
| 33 | + for line in text.splitlines(): |
| 34 | + if line.startswith("# "): |
| 35 | + return line[2:].strip() |
| 36 | + return None |
| 37 | + |
| 38 | + |
| 39 | +def _truncate(text: str, width: int) -> str: |
| 40 | + if width <= 0: |
| 41 | + return "" |
| 42 | + if len(text) <= width: |
| 43 | + return text |
| 44 | + if width <= 3: |
| 45 | + return text[:width] |
| 46 | + return text[: width - 3] + "..." |
| 47 | + |
| 48 | + |
| 49 | +def _make_separator(widths: Sequence[int]) -> str: |
| 50 | + return "+" + "+".join("-" * (width + 2) for width in widths) + "+" |
| 51 | + |
| 52 | + |
| 53 | +def _format_row(values: Sequence[str], widths: Sequence[int]) -> str: |
| 54 | + cells = [] |
| 55 | + for value, width in zip(values, widths, strict=True): |
| 56 | + cells.append(f" {_truncate(value, width).ljust(width)} ") |
| 57 | + return "|" + "|".join(cells) + "|" |
| 58 | + |
| 59 | + |
| 60 | +def _get_result_tags(result: SearchResult) -> str: |
| 61 | + metadata = result.metadata or {} |
| 62 | + if isinstance(metadata, dict): |
| 63 | + tags = metadata.get("tags") |
| 64 | + if isinstance(tags, list): |
| 65 | + return ", ".join(str(tag) for tag in tags if tag) |
| 66 | + return "" |
| 67 | + |
| 68 | + |
| 69 | +def _get_result_path(result: SearchResult) -> str: |
| 70 | + return result.permalink or result.file_path or "" |
| 71 | + |
| 72 | + |
| 73 | +def format_search_results_ascii( |
| 74 | + result: SearchResponse, |
| 75 | + query: str | None = None, |
| 76 | + color: bool = False, |
| 77 | +) -> str: |
| 78 | + """Format search results as an ASCII table for TUI clients.""" |
| 79 | + |
| 80 | + results = result.results or [] |
| 81 | + header_line = _apply_style("Search results", f"{ANSI_BOLD}{ANSI_CYAN}", color) |
| 82 | + lines = [header_line] |
| 83 | + |
| 84 | + if query: |
| 85 | + lines.append(f"Query: {query}") |
| 86 | + |
| 87 | + summary = f"Results: {len(results)} | Page: {result.current_page} | Page size: {result.page_size}" |
| 88 | + lines.append(_apply_style(summary, ANSI_DIM, color)) |
| 89 | + |
| 90 | + if not results: |
| 91 | + lines.append("No results.") |
| 92 | + return "\n".join(lines).strip() |
| 93 | + |
| 94 | + headers = ["#", "Title", "Type", "Score", "Path", "Tags"] |
| 95 | + rows = [] |
| 96 | + for idx, item in enumerate(results, start=1): |
| 97 | + rows.append( |
| 98 | + [ |
| 99 | + str(idx), |
| 100 | + item.title or "Untitled", |
| 101 | + item.type.value if hasattr(item.type, "value") else str(item.type), |
| 102 | + f"{item.score:.2f}" if isinstance(item.score, (int, float)) else "", |
| 103 | + _get_result_path(item), |
| 104 | + _get_result_tags(item), |
| 105 | + ] |
| 106 | + ) |
| 107 | + |
| 108 | + max_widths = [3, 32, 10, 7, 36, 24] |
| 109 | + widths = [] |
| 110 | + for index, header in enumerate(headers): |
| 111 | + column_values = [header] + [row[index] for row in rows] |
| 112 | + max_len = max(len(value) for value in column_values) |
| 113 | + widths.append(min(max_widths[index], max_len)) |
| 114 | + |
| 115 | + table = [_make_separator(widths)] |
| 116 | + header_row = _format_row(headers, widths) |
| 117 | + if color: |
| 118 | + header_cells = [] |
| 119 | + for value, width in zip(headers, widths, strict=True): |
| 120 | + padded = f" {_truncate(value, width).ljust(width)} " |
| 121 | + header_cells.append(_apply_style(padded, f"{ANSI_BOLD}{ANSI_CYAN}", color)) |
| 122 | + header_row = "|" + "|".join(header_cells) + "|" |
| 123 | + table.append(header_row) |
| 124 | + table.append(_make_separator(widths)) |
| 125 | + |
| 126 | + for row in rows: |
| 127 | + table.append(_format_row(row, widths)) |
| 128 | + |
| 129 | + table.append(_make_separator(widths)) |
| 130 | + |
| 131 | + lines.append("") |
| 132 | + lines.extend(table) |
| 133 | + return "\n".join(lines).rstrip() |
| 134 | + |
| 135 | + |
| 136 | +def format_note_preview_ascii( |
| 137 | + content: str, |
| 138 | + identifier: str | None = None, |
| 139 | + color: bool = False, |
| 140 | +) -> str: |
| 141 | + """Format note content for ASCII/TUI display.""" |
| 142 | + |
| 143 | + identifier = identifier or "" |
| 144 | + cleaned = _strip_frontmatter(content) |
| 145 | + title = _parse_title(cleaned) or identifier or "Note Preview" |
| 146 | + |
| 147 | + header = _apply_style("Note preview", f"{ANSI_BOLD}{ANSI_CYAN}", color) |
| 148 | + lines = [header, f"Title: {title}"] |
| 149 | + |
| 150 | + if identifier: |
| 151 | + lines.append(f"Identifier: {identifier}") |
| 152 | + |
| 153 | + lines.append(_apply_style("-" * 72, ANSI_DIM, color)) |
| 154 | + |
| 155 | + if content.strip(): |
| 156 | + lines.append(content.rstrip()) |
| 157 | + else: |
| 158 | + lines.append("(empty note)") |
| 159 | + |
| 160 | + return "\n".join(lines).rstrip() |
0 commit comments