From 97c33fdd5227d90b746bc8d5b6162011074a322a Mon Sep 17 00:00:00 2001 From: Sukanth Gunda Date: Mon, 4 May 2026 10:05:09 -0400 Subject: [PATCH 1/2] Render find results in adaptive ASCII table Change the find command output to use an adaptive ASCII table that wraps titles, folders, URLs, and excerpts to fit terminal widths. Adds helpers for determining output width and wrapping (_find_output_width, _wrap_cell, _split_long_token, _wrap_detail, _display_text, _find_table_widths) and a new _render_find_results function; the old line-by-line print logic is replaced with a call to this renderer. README updated to mention the adaptive table and tests updated to assert the new table format and include a wrapping behavior test. --- README.md | 2 + src/mindmark/cli.py | 154 +++++++++++++++++++++++++++++++++++++++---- tests/test_cli_ui.py | 43 ++++++++++-- 3 files changed, 182 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 635aee0..9a9b900 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,8 @@ mindmark find "helm chart examples" --domain github.com mindmark find "docker compose setup" --folder devops ``` +The default `find` output uses an adaptive ASCII table that wraps titles, folders, URLs, and excerpts cleanly across supported terminals and platforms. + ### 3️⃣ Open a result directly ```bash diff --git a/src/mindmark/cli.py b/src/mindmark/cli.py index b3b48c6..3ac0f5b 100644 --- a/src/mindmark/cli.py +++ b/src/mindmark/cli.py @@ -22,6 +22,7 @@ "brave": "Brave", "firefox": "Firefox", } +_WRAP_BREAK_AFTER = "/\\?&=#._-" def _console(args: argparse.Namespace) -> Console: @@ -252,6 +253,141 @@ def _format_score(score: object) -> str: return "n/a" +def _display_text(value: object, fallback: str = "") -> str: + text = "" if value is None else str(value) + text = " ".join(text.split()) + return text or fallback + + +def _find_output_width(console: Console) -> int: + is_tty = getattr(console.stdout, "isatty", lambda: False) + if is_tty(): + columns = shutil.get_terminal_size(fallback=(100, 24)).columns + else: + columns = 100 + return max(40, min(columns, 160)) + + +def _wrap_cell(text: str, width: int) -> list[str]: + width = max(1, width) + words = text.split() + if not words: + return [""] + + lines: list[str] = [] + current = "" + for word in words: + if len(word) > width: + if current: + lines.append(current) + current = "" + chunks = _split_long_token(word, width) + lines.extend(chunks[:-1]) + current = chunks[-1] + continue + + candidate = word if not current else f"{current} {word}" + if len(candidate) <= width: + current = candidate + else: + lines.append(current) + current = word + + if current: + lines.append(current) + return lines or [""] + + +def _split_long_token(token: str, width: int) -> list[str]: + chunks: list[str] = [] + remaining = token + while len(remaining) > width: + cut = max(remaining.rfind(ch, 1, width + 1) for ch in _WRAP_BREAK_AFTER) + if cut >= 0: + cut += 1 + else: + cut = width + chunks.append(remaining[:cut]) + remaining = remaining[cut:] + chunks.append(remaining) + return chunks + + +def _wrap_detail(label: str, value: object, width: int) -> list[str]: + text = _display_text(value) + if not text: + return [] + + prefix = f"{label}: " + content_width = max(1, width - len(prefix)) + chunks = _wrap_cell(text, content_width) + return [prefix + chunks[0], *[(" " * len(prefix)) + chunk for chunk in chunks[1:]]] + + +def _find_table_widths(total_width: int, result_count: int) -> tuple[int, int, int]: + index_width = max(2, len(str(result_count))) + score_width = len("Score") + title_width = max(12, total_width - index_width - score_width - 4) + return index_width, score_width, title_width + + +def _render_find_results( + console: Console, + results: list[dict], + *, + query: str, + include_excerpt: bool, +) -> None: + width = _find_output_width(console) + index_width, score_width, title_width = _find_table_widths( + width, + len(results), + ) + detail_indent = " " * (index_width + score_width + 4) + detail_width = max(20, width - len(detail_indent)) + + heading = f"Search results for {query!r} ({len(results)})" + for line in _wrap_cell(heading, width): + console.out(console.style(line, "bold")) + console.out() + + header = ( + f"{'No':>{index_width}} " + f"{'Score':<{score_width}} " + f"{'Title':<{title_width}}" + ) + divider = ( + f"{'-' * index_width} " + f"{'-' * score_width} " + f"{'-' * title_width}" + ) + console.out(console.style(header.rstrip(), "bold")) + console.out(divider) + + for i, result in enumerate(results, 1): + title = _display_text(result["title"], "(untitled)") + folder = _display_text(result.get("folder_path"), "(root)") + title_lines = _wrap_cell(title, title_width) + + for line_index, title_part in enumerate(title_lines): + number = str(i) if line_index == 0 else "" + score = _format_score(result.get("score")) if line_index == 0 else "" + row = ( + f"{number:>{index_width}} " + f"{score:<{score_width}} " + f"{title_part:<{title_width}}" + ) + console.out(row.rstrip()) + + for detail in _wrap_detail("Folder", folder, detail_width): + console.out(f"{detail_indent}{detail}") + for detail in _wrap_detail("URL", result["url"], detail_width): + console.out(f"{detail_indent}{detail}") + if include_excerpt and result.get("relevant_excerpt"): + for detail in _wrap_detail("Excerpt", result["relevant_excerpt"], detail_width): + console.out(f"{detail_indent}{detail}") + + def _cmd_find(args: argparse.Namespace) -> int: from .index import Index @@ -290,18 +426,12 @@ def _cmd_find(args: argparse.Namespace) -> int: _print_json(console, results, preserve_order=True) return 0 - for i, r in enumerate(results, 1): - folder = r.get("folder_path") or "(root)" - url = r["url"] - console.out(f"{i:2d}. {console.style(r['title'], 'bold')}") - console.out( - " " - f"score={console.style(_format_score(r.get('score')), 'accent')} " - f"folder={folder}" - ) - console.out(f" url={url}") - if include_excerpt and r.get("relevant_excerpt"): - console.out(f" ⤵ {r['relevant_excerpt']}") + _render_find_results( + console, + results, + query=args.query, + include_excerpt=include_excerpt, + ) console.hint(f"Open a result with: mindmark find {args.query!r} --open N") return 0 diff --git a/tests/test_cli_ui.py b/tests/test_cli_ui.py index cdf0e83..f721b30 100644 --- a/tests/test_cli_ui.py +++ b/tests/test_cli_ui.py @@ -91,15 +91,48 @@ def test_find_human_output_includes_score_url_folder_excerpt_and_hint(monkeypatc assert rc == 0 captured = capsys.readouterr() - assert "1. Example" in captured.out - assert "score=0.875" in captured.out - assert "folder=Work/Docs" in captured.out - assert "url=https://example.com/docs" in captured.out - assert "⤵ Helpful excerpt." in captured.out + assert "Search results for 'docs' (1)" in captured.out + assert "No Score" in captured.out + assert " 1 0.875 Example" in captured.out + assert "Work/Docs" in captured.out + assert "URL: https://example.com/docs" in captured.out + assert "Excerpt: Helpful excerpt." in captured.out + assert "score=" not in captured.out + assert "⤵" not in captured.out assert "Hint: Open a result with:" in captured.out assert captured.err == "" +def test_find_human_output_wraps_to_terminal_width(monkeypatch): + class TtyBuffer(io.StringIO): + def isatty(self): + return True + + out = TtyBuffer() + console = Console(color=False, stdout=out) + monkeypatch.setenv("COLUMNS", "56") + cli._render_find_results( + console, + [ + { + "score": 0.875, + "title": "A very long bookmark title that needs wrapping in narrow terminals", + "url": "https://example.com/really/long/path/that/also/needs/wrapping", + "folder_path": "Favorites bar/Work/Very Long Folder Name", + "domain": "example.com", + "relevant_excerpt": "A long excerpt that should wrap without using non-ASCII markers.", + } + ], + query="docs", + include_excerpt=True, + ) + + lines = out.getvalue().splitlines() + assert max(len(line) for line in lines) <= 56 + assert "URL:" in out.getvalue() + assert "Excerpt:" in out.getvalue() + + def test_find_json_preserves_result_list_and_has_no_color(monkeypatch, capsys): _FakeIndex.search_results = [ { From 25d42fda8539580e06ee378d00dfce555a856fc9 Mon Sep 17 00:00:00 2001 From: Sukanth Gunda Date: Mon, 4 May 2026 10:05:43 -0400 Subject: [PATCH 2/2] Bump version from 0.1.7 to 0.1.8 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 02c1a13..faa5e06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mindmark" -version = "0.1.7" +version = "0.1.8" description = "Local semantic search over your browser bookmarks — on-device embeddings, no cloud." readme = "README.md" requires-python = ">=3.9"