Skip to content

Commit b5cdcf0

Browse files
authored
feat: Add progress bar and pretty print for CLI (#261)
1 parent 8e52ea7 commit b5cdcf0

7 files changed

Lines changed: 59 additions & 11 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ semble find-related src/auth.py 42 ./my-project
103103
semble search "authentication flow" ./my-project --max-snippet-lines 10
104104
```
105105

106-
`--content` accepts `code` (default), `docs`, `config`, or `all`. `path` defaults to the current directory when omitted; git URLs are accepted. If `semble` is not on `$PATH`, use `uvx --from "semble[mcp]" semble` in its place. `semble --version` (or `-V`) prints the installed version.
106+
`--content` accepts `code` (default), `docs`, `config`, or `all`. `--format` accepts `json` (default) or `text`. `path` defaults to the current directory when omitted; git URLs are accepted. If `semble` is not on `$PATH`, use `uvx --from "semble[mcp]" semble` in its place. `semble --version` (or `-V`) prints the installed version.
107107

108108
<details>
109109
<summary>Controlling which files are indexed</summary>

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ dependencies = [
3333
"orjson",
3434
"questionary>=2.0,<3.0",
3535
"semble-grammars>=0.1.2",
36+
"tqdm>=4.60",
3637
]
3738

3839
[project.optional-dependencies]

‎src/semble/cli.py‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@
3131

3232

3333
def _build_index(path: str, content: list[ContentType]) -> SembleIndex:
34-
"""Build an index from a local path or git URL."""
34+
"""Build an index from a local path or git URL, showing a progress bar on a tty."""
35+
show_progress_bar = sys.stderr.isatty() # Only show the progress bar in a terminal
3536
return (
36-
SembleIndex.from_git(path, content=content)
37+
SembleIndex.from_git(path, content=content, show_progress_bar=show_progress_bar)
3738
if is_git_url(path)
38-
else SembleIndex.from_path(path, content=content)
39+
else SembleIndex.from_path(path, content=content, show_progress_bar=show_progress_bar)
3940
)
4041

4142

@@ -114,17 +115,37 @@ def _load_index(path: str, content: list[ContentType]) -> SembleIndex:
114115
sys.exit(1)
115116

116117

117-
def _run_search(path: str, query: str, top_k: int, content: list[ContentType], max_snippet_lines: int | None) -> None:
118+
def _print_results(out: dict, output_format: str) -> None:
119+
"""Print a format_results() payload as JSON or as human-readable text."""
120+
if output_format == "json":
121+
print(json.dumps(out))
122+
elif "error" in out:
123+
print(out["error"])
124+
else:
125+
for r in out["results"]:
126+
snippet = f"\n\n{r['content']}" if "content" in r else ""
127+
print(f"{r['file_path']}:{r['start_line']}-{r['end_line']}{snippet}\n")
128+
129+
130+
def _run_search(
131+
path: str, query: str, top_k: int, content: list[ContentType], max_snippet_lines: int | None, output_format: str
132+
) -> None:
118133
"""Handle the `search` subcommand."""
119134
index = _load_index(path, content)
120135
results = index.search(query, top_k=top_k, max_snippet_lines=max_snippet_lines)
121136
out = format_results(query, results, max_snippet_lines) if results else {"error": "No results found."}
122-
print(json.dumps(out))
137+
_print_results(out, output_format)
123138
_maybe_save_index(index, path)
124139

125140

126141
def _run_find_related(
127-
path: str, file_path: str, line: int, top_k: int, content: list[ContentType], max_snippet_lines: int | None
142+
path: str,
143+
file_path: str,
144+
line: int,
145+
top_k: int,
146+
content: list[ContentType],
147+
max_snippet_lines: int | None,
148+
output_format: str,
128149
) -> None:
129150
"""Handle the `find-related` subcommand."""
130151
index = _load_index(path, content)
@@ -139,7 +160,7 @@ def _run_find_related(
139160
if results
140161
else {"error": f"No related chunks found for {file_path}:{line}."}
141162
)
142-
print(json.dumps(out))
163+
_print_results(out, output_format)
143164
_maybe_save_index(index, path)
144165

145166

@@ -241,6 +262,7 @@ def _cli_main() -> None:
241262
metavar="N",
242263
help="Lines of source per result (default: full chunk). 10 = signature + body, 0 = no code.",
243264
)
265+
search_p.add_argument("--format", choices=["json", "text"], default="json", help="Output format (default: json).")
244266
_add_content_args(search_p)
245267

246268
clear_p = sub.add_parser("clear", help="Clear the index cache.")
@@ -262,6 +284,7 @@ def _cli_main() -> None:
262284
metavar="N",
263285
help="Lines of source per result (default: full chunk). 10 = signature + body, 0 = no code.",
264286
)
287+
related_p.add_argument("--format", choices=["json", "text"], default="json", help="Output format (default: json).")
265288
_add_content_args(related_p)
266289

267290
sub.add_parser("savings", help="Show token savings and usage stats.")
@@ -311,6 +334,7 @@ def _cli_main() -> None:
311334
args.top_k,
312335
_resolve_content(args.content, args.include_text_files),
313336
args.max_snippet_lines,
337+
args.format,
314338
)
315339
elif args.command == "find-related":
316340
_run_find_related(
@@ -320,4 +344,5 @@ def _cli_main() -> None:
320344
args.top_k,
321345
_resolve_content(args.content, args.include_text_files),
322346
args.max_snippet_lines,
347+
args.format,
323348
)

‎src/semble/index/create.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import contextlib
22
import logging
3+
import sys
34
from collections.abc import Sequence
45
from pathlib import Path
56

67
import numpy as np
78
from model2vec.model import StaticModel
9+
from tqdm import tqdm
810
from vicinity.backends.basic import BasicArgs
911

1012
from semble.chunking import chunk_source
@@ -72,6 +74,7 @@ def create_index_from_path(
7274
content: ContentType | Sequence[ContentType] = (ContentType.CODE,),
7375
display_root: Path | None = None,
7476
previous: PreviousIndex | None = None,
77+
show_progress_bar: bool = False,
7578
) -> tuple[BM25, SelectableBasicBackend, list[Chunk], dict[str, FileManifestEntry]]:
7679
"""Create an index from a resolved directory, optionally reusing a previous index's unchanged files.
7780
@@ -80,6 +83,7 @@ def create_index_from_path(
8083
:param content: Content types to index.
8184
:param display_root: If set, chunk file paths are stored relative to this root.
8285
:param previous: A previously built index to reuse unchanged files' chunks/embeddings/postings from.
86+
:param show_progress_bar: Show a progress bar on stderr while indexing.
8387
:raises ValueError: if no items were found, no index can be created.
8488
:return: A BM25 index, semantic index, list of chunks, and file manifest.
8589
"""
@@ -98,7 +102,10 @@ def create_index_from_path(
98102

99103
skipped_large: list[str] = []
100104

101-
for file_path in walk_files(path, resolved_extensions):
105+
files = list(walk_files(path, resolved_extensions))
106+
for file_path in tqdm(
107+
files, desc="Indexing", unit="file", file=sys.stderr, leave=False, colour="green", disable=not show_progress_bar
108+
):
102109
language = detect_language(file_path)
103110
with contextlib.suppress(OSError):
104111
file_status = get_file_status(file_path, None)

‎src/semble/index/index.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,15 @@ def from_path(
136136
content: ContentType | Sequence[ContentType] = _DEFAULT_CONTENT,
137137
include_text_files: bool | None = None,
138138
model_path: str | None = None,
139+
show_progress_bar: bool = False,
139140
) -> SembleIndex:
140141
"""Create and index a SembleIndex from a directory.
141142
142143
:param path: Root directory to index.
143144
:param content: Content types to index, e.g. ContentType.CODE or [ContentType.CODE, ContentType.DOCS].
144145
:param include_text_files: Deprecated. Pass a content sequence directly instead.
145146
:param model_path: Path to the model to use. If None, the default model will be used.
147+
:param show_progress_bar: Show a progress bar while indexing.
146148
:return: An indexed SembleIndex. Chunk file paths are relative to ``path``.
147149
:raises FileNotFoundError: If `path` does not exist.
148150
:raises NotADirectoryError: If `path` exists but is not a directory.
@@ -167,6 +169,7 @@ def from_path(
167169
content=normalized,
168170
display_root=path,
169171
previous=previous,
172+
show_progress_bar=show_progress_bar,
170173
)
171174

172175
return SembleIndex(
@@ -181,6 +184,7 @@ def from_git(
181184
model_path: str | None = None,
182185
content: ContentType | Sequence[ContentType] = _DEFAULT_CONTENT,
183186
include_text_files: bool | None = None,
187+
show_progress_bar: bool = False,
184188
) -> SembleIndex:
185189
"""Clone a git repository and index it.
186190
@@ -194,6 +198,7 @@ def from_git(
194198
:param model_path: Path to the model to use. If None, the default model will be used.
195199
:param content: Content types to index, e.g. (ContentType.CODE,) or (ContentType.CODE, ContentType.DOCS).
196200
:param include_text_files: Deprecated. Pass content=(ContentType.CODE, ContentType.DOCS, ...) instead.
201+
:param show_progress_bar: Show a progress bar while indexing.
197202
:return: An indexed SembleIndex. Chunk file paths are repo-relative (e.g. ``src/foo.py``).
198203
:raises RuntimeError: If git is not on PATH, the clone fails, or times out.
199204
"""
@@ -224,6 +229,7 @@ def from_git(
224229
model=model,
225230
content=normalized,
226231
display_root=resolved_path,
232+
show_progress_bar=show_progress_bar,
227233
)
228234

229235
return SembleIndex(

‎tests/test_cli.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ def test_main_calls_asyncio_run(argv: list[str], monkeypatch: pytest.MonkeyPatch
3434
[
3535
(["semble", "search", "query text", "/some/path"], ["query text", "0.9"]),
3636
(["semble", "search", "nothing", "/some/path", "--top-k", "3"], ["No results found"]),
37+
(["semble", "search", "query text", "/some/path", "--format", "text"], ["src/foo.py:1-1\n\ndef foo(): pass"]),
38+
(["semble", "search", "nothing", "/some/path", "--format", "text"], ["No results found."]),
3739
],
3840
)
3941
def test_cli_search(
@@ -59,6 +61,7 @@ def test_cli_search(
5961
("scenario", "expected_stdout", "expected_stderr", "expected_exit_code"),
6062
[
6163
("with_results", ["src/bar.py", "0.8"], None, None),
64+
("text", ["src/bar.py:1-1\n\nclass Bar: pass"], None, None),
6265
("no_results", ["No related chunks found"], None, None),
6366
("unknown_chunk", [], "No chunk found", 1),
6467
],
@@ -75,9 +78,13 @@ def test_cli_find_related(
7578
chunk = make_chunk("class Bar: pass", "src/bar.py")
7679
fake_index = MagicMock()
7780
fake_index.chunks = [] if scenario == "unknown_chunk" else [chunk]
78-
fake_index.find_related.return_value = [SearchResult(chunk=chunk, score=0.8)] if scenario == "with_results" else []
81+
has_results = scenario in ("with_results", "text")
82+
fake_index.find_related.return_value = [SearchResult(chunk=chunk, score=0.8)] if has_results else []
7983
file_path = "unknown.py" if scenario == "unknown_chunk" else "src/bar.py"
80-
monkeypatch.setattr(sys, "argv", ["semble", "find-related", file_path, "1", "/some/path"])
84+
argv = ["semble", "find-related", file_path, "1", "/some/path"] + (
85+
["--format", "text"] if scenario == "text" else []
86+
)
87+
monkeypatch.setattr(sys, "argv", argv)
8188
with patch("semble.cli.SembleIndex.from_path", return_value=fake_index):
8289
if expected_exit_code is None:
8390
_cli_main()

‎uv.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)