nabla-cli 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/PKG-INFO +1 -1
  2. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/__init__.py +1 -1
  3. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/branding.py +15 -9
  4. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/cli.py +76 -36
  5. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/recorder.py +13 -5
  6. nabla_cli-0.4.0/nabla/ui.py +170 -0
  7. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/PKG-INFO +1 -1
  8. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/SOURCES.txt +1 -0
  9. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/pyproject.toml +1 -1
  10. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/README.md +0 -0
  11. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/__main__.py +0 -0
  12. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/config.py +0 -0
  13. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/contract.py +0 -0
  14. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/induce.py +0 -0
  15. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/profile.py +0 -0
  16. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/prompt_recon.py +0 -0
  17. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/samplegen.py +0 -0
  18. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/scanner.py +0 -0
  19. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/schema_resolver.py +0 -0
  20. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/schemas/site_profile.schema.json +0 -0
  21. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/term.py +0 -0
  22. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
  23. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/entry_points.txt +0 -0
  24. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/requires.txt +0 -0
  25. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/top_level.txt +0 -0
  26. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/setup.cfg +0 -0
  27. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_cli_ux.py +0 -0
  28. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_config.py +0 -0
  29. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_e2e_profile.py +0 -0
  30. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_induce.py +0 -0
  31. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_prompt_recon.py +0 -0
  32. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_recorder.py +0 -0
  33. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_samplegen.py +0 -0
  34. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_scanner.py +0 -0
  35. {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_schema_resolver.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -1,3 +1,3 @@
1
1
  """nabla — call-site harvester CLI (pre-Phase-0)."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -13,12 +13,16 @@ from rich.text import Text
13
13
 
14
14
  from . import __version__
15
15
 
16
+ # Proportioned after the wordmark: tall triangle, heavy top bar,
17
+ # strokes converging to a centered apex.
16
18
  _LOGO = [
17
- "▛▀▀▀▀▀▀▀▜",
18
- " ▞",
19
- " ▞",
20
- " ▞",
21
- "",
19
+ "▛▀▀▀▀▀▀▀▀▀▜",
20
+ "▚ ▞",
21
+ " ▞",
22
+ " ▞",
23
+ " ▚ ▞",
24
+ " ▚ ▞",
25
+ " ▀",
22
26
  ]
23
27
 
24
28
  _BRAND_STYLE = "bold cyan"
@@ -28,10 +32,12 @@ def banner_lines() -> list[tuple[str, str]]:
28
32
  """(logo_line, tagline) pairs; taglines are empty on the outer rows."""
29
33
  taglines = [
30
34
  "",
31
- f"nabla v{__version__}",
32
- "OpenAI call-site harvester",
35
+ "",
36
+ "nabla",
37
+ f"v{__version__} · OpenAI call-site harvester",
33
38
  "scan · record · profile",
34
39
  "",
40
+ "",
35
41
  ]
36
42
  return list(zip(_LOGO, taglines))
37
43
 
@@ -43,8 +49,8 @@ def print_banner(console: Console | None = None) -> None:
43
49
  console.print()
44
50
  for logo_line, tagline in banner_lines():
45
51
  row = Text()
46
- row.append(f" {logo_line:<12}", style=_BRAND_STYLE)
52
+ row.append(f" {logo_line:<14}", style=_BRAND_STYLE)
47
53
  if tagline:
48
- row.append(tagline, style="bold" if tagline.startswith("nabla") else "dim")
54
+ row.append(tagline, style="bold" if tagline == "nabla" else "dim")
49
55
  console.print(row)
50
56
  console.print()
@@ -220,24 +220,24 @@ def _read_api_key(prompt: str) -> str:
220
220
 
221
221
 
222
222
  def _cmd_init(args) -> int:
223
+ from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
224
+
223
225
  print_banner(_out)
226
+ rail_start(_out, "nabla init")
227
+ rail(_out)
228
+
224
229
  provider = args.provider
225
230
  if provider is None:
226
231
  names = list(PROVIDERS)
227
- _out.print("Pick a provider:", style="bold")
228
- for i, name in enumerate(names, start=1):
229
- _out.print(f" [cyan]{i})[/cyan] [bold]{name}[/bold] — "
230
- f"{_PROVIDER_DESCRIPTIONS.get(name, '')}")
231
- choice = input("> ").strip()
232
- if choice.isdigit() and 1 <= int(choice) <= len(names):
233
- provider = names[int(choice) - 1]
234
- else:
235
- provider = choice
232
+ options = [(name, _PROVIDER_DESCRIPTIONS.get(name, "")) for name in names]
233
+ provider = names[select(_out, "Provider", options)]
234
+ else:
235
+ rail(_out, f"Provider {provider}")
236
236
 
237
237
  api_key = args.api_key
238
238
  if api_key is None:
239
239
  key_env = PROVIDERS.get(provider, {}).get("api_key_env", "API key")
240
- api_key = _read_api_key(f"Paste your {key_env}: ")
240
+ api_key = _read_api_key(f"{RAIL} {key_env} ")
241
241
 
242
242
  if not api_key:
243
243
  _fail("nabla init: no API key given")
@@ -250,12 +250,14 @@ def _cmd_init(args) -> int:
250
250
  return 2
251
251
 
252
252
  masked = f"...{api_key[-4:]}" if len(api_key) >= 4 else "..."
253
- _ok(f"nabla init: saved to {path} (key: {masked})")
253
+ rail(_out)
254
+ rail_ok(_out, f"saved to {path} (key: {masked})")
254
255
  try:
255
256
  _ensure_scripts_on_path()
256
257
  except Exception:
257
258
  pass # PATH help is best-effort; setup itself already succeeded
258
- _next_step("nabla scan (run it from inside the repo you want to harvest)")
259
+ rail(_out)
260
+ rail_end(_out, "next: nabla scan — run it from inside the repo you want to harvest")
259
261
  return 0
260
262
 
261
263
 
@@ -330,7 +332,9 @@ def _cmd_scan(args) -> int:
330
332
  for col in ("SYMBOL", "FILE", "KIND", "VERIFIABILITY", "HARVESTABLE", "FLAGS"):
331
333
  table.add_column(col, overflow="fold")
332
334
  supported_count = 0
333
- for r in rows:
335
+ # Harvestable sites first — the reader's question is "what can I use?"
336
+ keyed = sorted(rows, key=lambda r: not is_supported(Verifiability(r["verifiability"]), r["flags"]))
337
+ for r in keyed:
334
338
  verif = r["verifiability"]
335
339
  harvestable = is_supported(Verifiability(verif), r["flags"])
336
340
  if harvestable:
@@ -342,12 +346,13 @@ def _cmd_scan(args) -> int:
342
346
  else:
343
347
  verif_color = "dim"
344
348
  table.add_row(
345
- Text(r["symbol"], style="cyan"),
349
+ Text(r["symbol"], style="cyan" if harvestable else "dim"),
346
350
  r["file"],
347
351
  r["kind"],
348
352
  Text(verif, style=verif_color),
349
353
  Text("yes", style="green") if harvestable else Text("no", style="dim"),
350
354
  ",".join(r["flags"]) or "-",
355
+ style=None if harvestable else "dim",
351
356
  )
352
357
  _out.print(table)
353
358
  noun = "call site" if len(rows) == 1 else "call sites"
@@ -389,8 +394,8 @@ def _resolve_site(args, sites):
389
394
 
390
395
  if len(supported) == 1:
391
396
  site = supported[0]
392
- _out.print(f"nabla: using site {site.symbol} (the only supported call site)",
393
- style="dim", markup=False)
397
+ from .ui import rail
398
+ rail(_out, f"using site {site.symbol} (the only supported call site)", style="dim")
394
399
  return site, None
395
400
 
396
401
  candidates = supported or sites
@@ -410,10 +415,15 @@ def _resolve_site(args, sites):
410
415
 
411
416
 
412
417
  def _cmd_record(args) -> int:
418
+ from .ui import make_progress, rail, rail_end, rail_ok, rail_start
419
+
420
+ rail_start(_out, "nabla record")
413
421
  sites = scan(args.repo)
414
422
  site, err = _resolve_site(args, sites)
415
423
  if err is not None:
416
424
  return err
425
+ if args.site:
426
+ rail(_out, f"site {site.symbol} · {site.file}", style="dim")
417
427
 
418
428
  if _unconfigured():
419
429
  _warn(
@@ -432,7 +442,8 @@ def _cmd_record(args) -> int:
432
442
  else:
433
443
  conventional = Path(f"{site.symbol}.samples.jsonl")
434
444
  if conventional.exists():
435
- print(f"nabla record: using {conventional} (found by convention; pass --samples to override)")
445
+ rail(_out, f"using {conventional} (found by convention; pass --samples to override)",
446
+ style="dim")
436
447
  source = RecordSource(kind="samples", samples_file=str(conventional))
437
448
  else:
438
449
  source, err = _generate_samples_source(args, site, conventional)
@@ -440,8 +451,23 @@ def _cmd_record(args) -> int:
440
451
  return err
441
452
  source_kind = "generated_samples"
442
453
 
454
+ on_progress = None
455
+ progress = None
456
+ if _out.is_terminal and source.kind == "samples":
457
+ progress = make_progress(_out)
458
+ task_ref: list = []
459
+
460
+ def on_progress(done: int, total: int) -> None:
461
+ if not task_ref:
462
+ task_ref.append(progress.add_task("recording", total=total))
463
+ progress.update(task_ref[0], completed=done)
464
+
443
465
  try:
444
- examples = record(site, source, args.repo)
466
+ if progress is not None:
467
+ with progress:
468
+ examples = record(site, source, args.repo, on_progress=on_progress)
469
+ else:
470
+ examples = record(site, source, args.repo)
445
471
  except ZeroTrafficError as exc:
446
472
  _fail(f"nabla record: {exc}")
447
473
  return 1
@@ -449,15 +475,17 @@ def _cmd_record(args) -> int:
449
475
  with open(out, "w", encoding="utf-8") as f:
450
476
  for ex in examples:
451
477
  f.write(json.dumps({**ex.to_dict(), "_source_kind": source_kind or source.kind}) + "\n")
452
- _ok(f"recorded {len(examples)} (prompt, completion) pairs from "
453
- f"site {site.symbol} -> {out}")
478
+ rail(_out)
479
+ rail_ok(_out, f"recorded {len(examples)} (prompt, completion) pairs from "
480
+ f"site {site.symbol} -> {out}")
454
481
  if source_kind == "generated_samples":
455
- _warn(f"inputs were synthetic — edit {site.symbol}.samples.jsonl with real "
456
- "data and rerun for a higher-fidelity harvest")
482
+ rail(_out, f"inputs were synthetic — edit {site.symbol}.samples.jsonl with real "
483
+ "data and rerun for a higher-fidelity harvest", style="yellow")
484
+ rail(_out)
457
485
  if args.out is None and args.site is None:
458
- _next_step(f"nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
486
+ rail_end(_out, f"next: nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
459
487
  else:
460
- _next_step(f"nabla profile {args.repo} --site {site.symbol} --examples {out}")
488
+ rail_end(_out, f"next: nabla profile {args.repo} --site {site.symbol} --examples {out}")
461
489
  return 0
462
490
 
463
491
 
@@ -482,8 +510,9 @@ def _generate_samples_source(args, site, out_path: Path):
482
510
  )
483
511
  return None, 1
484
512
  out_path.write_text("\n".join(json.dumps(item) for item in items) + "\n", encoding="utf-8")
485
- print(f"nabla record: no samples given — generated {len(items)} synthetic inputs -> {out_path} "
486
- "(edit this file and rerun to use realistic inputs)")
513
+ from .ui import rail
514
+ rail(_out, f"no samples given generated {len(items)} synthetic inputs -> {out_path} "
515
+ "(edit this file and rerun to use realistic inputs)", style="dim")
487
516
  return RecordSource(kind="samples", samples_file=str(out_path)), None
488
517
 
489
518
 
@@ -491,11 +520,15 @@ def _cmd_profile(args) -> int:
491
520
  from .induce import induce
492
521
  from .prompt_recon import reconstruct
493
522
  from .schema_resolver import resolve_schema as _resolve
523
+ from .ui import rail, rail_end, rail_ok, rail_start
494
524
 
525
+ rail_start(_out, "nabla profile")
495
526
  sites = scan(args.repo)
496
527
  site, err = _resolve_site(args, sites)
497
528
  if err is not None:
498
529
  return err
530
+ if args.site:
531
+ rail(_out, f"site {site.symbol} · {site.file}", style="dim")
499
532
 
500
533
  examples_path = args.examples
501
534
  if examples_path is None:
@@ -512,7 +545,7 @@ def _cmd_profile(args) -> int:
512
545
  print(f"nabla profile: no recorded examples found ({conventional} or recorded.jsonl). "
513
546
  f"Run first: {record_cmd}", file=sys.stderr)
514
547
  return 2
515
- print(f"nabla profile: using {examples_path}")
548
+ rail(_out, f"using {examples_path}", style="dim")
516
549
 
517
550
  examples, recorded_from = [], "samples"
518
551
  for line in Path(examples_path).read_text(encoding="utf-8").splitlines():
@@ -528,22 +561,29 @@ def _cmd_profile(args) -> int:
528
561
 
529
562
  template = reconstruct(site, args.repo)
530
563
  schema, _ = _resolve(site, args.repo)
531
- goal = induce(template, schema, examples)
564
+ if _out.is_terminal:
565
+ with _out.status(f"inferring goal from {len(examples)} examples", spinner="dots"):
566
+ goal = induce(template, schema, examples)
567
+ else:
568
+ goal = induce(template, schema, examples)
532
569
 
533
570
  profile = build_profile(site, args.repo, examples, goal, recorded_from=recorded_from)
534
571
  out = args.out or f"{site.symbol}.profile.json"
535
572
  Path(out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
536
573
  supported = profile["classification"]["supported"]
537
- _ok(f"wrote {out} — the handoff artifact for site {site.symbol}")
538
- _out.print(f" site id {profile['site_id'][:16]}", markup=False)
539
- _out.print(f" supported {supported}", markup=False)
540
- _out.print(f" examples {len(examples)} ({recorded_from})", markup=False)
541
- _out.print(f" goal {goal.description[:90]}", markup=False)
574
+ rail(_out)
575
+ rail_ok(_out, f"wrote {out}")
576
+ rail(_out, f"site {site.symbol} · {profile['site_id'][:16]}…")
577
+ rail(_out, f"supported {supported}")
578
+ rail(_out, f"examples {len(examples)} ({recorded_from})")
579
+ rail(_out, f"goal {goal.description[:90]}")
542
580
  dims = ", ".join(d["name"] for d in goal.difficulty_dimensions[:4])
543
- _out.print(f" difficulty {dims}", markup=False)
581
+ rail(_out, f"difficulty {dims}")
544
582
  if len(examples) < 20:
545
- _warn(f"only {len(examples)} examples (< 20) — enough for a look, "
546
- "but a handoff-quality profile wants 20+ real-traffic pairs")
583
+ rail(_out, f"only {len(examples)} examples (< 20) — enough for a look, but a "
584
+ "handoff-quality profile wants 20+ real-traffic pairs", style="yellow")
585
+ rail(_out)
586
+ rail_end(_out, f"done — {out} is the handoff artifact", style="green")
547
587
  return 0
548
588
 
549
589
 
@@ -421,7 +421,8 @@ def _run_site_in_subprocess(site: RawSite, repo_path: str, input_slots: dict, en
421
421
  )
422
422
 
423
423
 
424
- def _record_samples(site: RawSite, source: RecordSource, repo_path: str) -> list[Example]:
424
+ def _record_samples(site: RawSite, source: RecordSource, repo_path: str,
425
+ on_progress=None) -> list[Example]:
425
426
  if not source.samples_file:
426
427
  raise ValueError("RecordSource.kind='samples' requires samples_file")
427
428
  sample_lines = _read_jsonl(source.samples_file)
@@ -466,8 +467,11 @@ def _record_samples(site: RawSite, source: RecordSource, repo_path: str) -> list
466
467
  new_captures = list(proxy.captured[before:])
467
468
  for capture in new_captures:
468
469
  examples.append(_capture_to_example(capture, input_slots))
469
- print(f"nabla record: sample {i + 1}/{total} -> {len(new_captures)} capture(s)",
470
- file=sys.stderr)
470
+ if on_progress is not None:
471
+ on_progress(i + 1, total)
472
+ else:
473
+ print(f"nabla record: sample {i + 1}/{total} -> {len(new_captures)} capture(s)",
474
+ file=sys.stderr)
471
475
  finally:
472
476
  proxy.stop()
473
477
 
@@ -544,15 +548,19 @@ def _record_logs(site: RawSite, source: RecordSource, repo_path: str) -> list[Ex
544
548
  # Public interface
545
549
  # ---------------------------------------------------------------------------
546
550
 
547
- def record(site: RawSite, source: RecordSource, repo_path: str) -> list[Example]:
551
+ def record(site: RawSite, source: RecordSource, repo_path: str,
552
+ on_progress=None) -> list[Example]:
548
553
  """Capture real (prompt, completion, tokens, latency) pairs for site.
549
554
 
550
555
  Must: attribute captured requests back to the site (template match),
551
556
  populate token counts and latency, raise ZeroTrafficError on an empty
552
557
  proxy run instead of returning [].
558
+
559
+ ``on_progress(done, total)`` (samples path only) replaces the default
560
+ per-sample stderr lines — used by the CLI to drive a progress bar.
553
561
  """
554
562
  if source.kind == "samples":
555
- return _record_samples(site, source, repo_path)
563
+ return _record_samples(site, source, repo_path, on_progress=on_progress)
556
564
  if source.kind == "proxy":
557
565
  return _record_proxy(site, source, repo_path)
558
566
  if source.kind == "logs":
@@ -0,0 +1,170 @@
1
+ """nabla's terminal design system.
2
+
3
+ One visual language across commands:
4
+
5
+ - a rail (``┌ │ └``) threads each multi-step flow so the user always sees
6
+ where they are in the sequence — structure as information, not decoration;
7
+ - selection is pointer-driven (arrow keys) on an interactive Windows
8
+ console, with a numbered fallback everywhere else;
9
+ - progress fills with the nabla glyph itself — the one flourish;
10
+ - color carries meaning only: cyan = brand/interactive, green = done,
11
+ yellow = caution, red = failure, dim = structure.
12
+
13
+ Glyphs are box-drawing / geometric only — never emoji.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+
20
+ from rich.console import Console
21
+ from rich.progress import Progress, ProgressColumn, TextColumn
22
+ from rich.text import Text
23
+
24
+ RAIL = "│"
25
+ _POINTER = "❯"
26
+ _ON = "●"
27
+ _OFF = "○"
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Rail primitives
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def rail_start(console: Console, title: str) -> None:
35
+ line = Text("┌ ", style="dim")
36
+ line.append(title, style="bold")
37
+ console.print(line)
38
+
39
+
40
+ def rail(console: Console, text: str = "", style: str | None = None) -> None:
41
+ line = Text(RAIL, style="dim")
42
+ if text:
43
+ line.append(" " + text, style=style or "")
44
+ console.print(line)
45
+
46
+
47
+ def rail_ok(console: Console, text: str) -> None:
48
+ line = Text(f"{RAIL} ", style="dim")
49
+ line.append("✓ ", style="green")
50
+ line.append(text)
51
+ console.print(line)
52
+
53
+
54
+ def rail_end(console: Console, text: str, style: str = "cyan") -> None:
55
+ line = Text("└ ", style="dim")
56
+ line.append(text, style=style)
57
+ console.print(line)
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Selection
62
+ # ---------------------------------------------------------------------------
63
+
64
+ def select(console: Console, label: str, options: list[tuple[str, str]],
65
+ default: int = 0) -> int:
66
+ """Pick one of ``options`` ([(name, description), ...]); returns the index.
67
+
68
+ Interactive Windows console: arrow keys + enter, pointer rendering,
69
+ menu collapses to a single confirmation line after the choice.
70
+ Anywhere else: numbered prompt.
71
+ """
72
+ interactive = (
73
+ sys.platform == "win32"
74
+ and console.is_terminal
75
+ and sys.stdin.isatty()
76
+ )
77
+ if not interactive:
78
+ rail(console, label, style="bold")
79
+ for i, (name, desc) in enumerate(options, start=1):
80
+ rail(console, f" {i}) {name} — {desc}")
81
+ choice = input(f"{RAIL} > ").strip()
82
+ if choice.isdigit() and 1 <= int(choice) <= len(options):
83
+ return int(choice) - 1
84
+ for i, (name, _) in enumerate(options):
85
+ if name == choice:
86
+ return i
87
+ return default
88
+
89
+ import msvcrt
90
+
91
+ from .term import init_terminal
92
+
93
+ init_terminal()
94
+ idx = default
95
+ name_width = max(len(name) for name, _ in options)
96
+ hint = "↑ ↓ move · enter chooses"
97
+ lines_drawn = 0
98
+
99
+ def draw() -> None:
100
+ nonlocal lines_drawn
101
+ if lines_drawn:
102
+ sys.stdout.write(f"\x1b[{lines_drawn}A\x1b[0J")
103
+ header = Text(f"{RAIL} ", style="dim")
104
+ header.append(label, style="bold")
105
+ header.append(f" {hint}", style="dim")
106
+ console.print(header)
107
+ for i, (name, desc) in enumerate(options):
108
+ row = Text(f"{RAIL} ", style="dim")
109
+ if i == idx:
110
+ row.append(f" {_POINTER} {_ON} ", style="cyan")
111
+ row.append(f"{name:<{name_width}}", style="bold cyan")
112
+ else:
113
+ row.append(f" {_OFF} ", style="dim")
114
+ row.append(f"{name:<{name_width}}", style="dim")
115
+ row.append(f" {desc}", style="dim")
116
+ console.print(row)
117
+ lines_drawn = len(options) + 1
118
+
119
+ draw()
120
+ while True:
121
+ ch = msvcrt.getwch()
122
+ if ch in ("\r", "\n"):
123
+ break
124
+ if ch == "\x03":
125
+ raise KeyboardInterrupt
126
+ if ch in ("\xe0", "\x00"):
127
+ arrow = msvcrt.getwch()
128
+ if arrow == "H":
129
+ idx = (idx - 1) % len(options)
130
+ elif arrow == "P":
131
+ idx = (idx + 1) % len(options)
132
+ draw()
133
+
134
+ # Collapse the menu into one confirmation line.
135
+ sys.stdout.write(f"\x1b[{lines_drawn}A\x1b[0J")
136
+ chosen = Text(f"{RAIL} ", style="dim")
137
+ chosen.append(f"{label} ", style="bold")
138
+ chosen.append(options[idx][0], style="cyan")
139
+ console.print(chosen)
140
+ return idx
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Progress: the ∇ bar
145
+ # ---------------------------------------------------------------------------
146
+
147
+ class NablaBar(ProgressColumn):
148
+ """A bar that fills with the nabla glyph — brand as instrument."""
149
+
150
+ def __init__(self, width: int = 28):
151
+ super().__init__()
152
+ self.width = width
153
+
154
+ def render(self, task) -> Text:
155
+ total = task.total or 1
156
+ filled = int(self.width * min(task.completed / total, 1.0))
157
+ bar = Text()
158
+ bar.append("∇" * filled, style="cyan")
159
+ bar.append("·" * (self.width - filled), style="dim")
160
+ return bar
161
+
162
+
163
+ def make_progress(console: Console, unit: str = "samples") -> Progress:
164
+ return Progress(
165
+ TextColumn(RAIL, style="dim"),
166
+ NablaBar(),
167
+ TextColumn(f"{{task.completed}}/{{task.total}} {unit}", style="dim"),
168
+ console=console,
169
+ transient=True,
170
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -14,6 +14,7 @@ nabla/samplegen.py
14
14
  nabla/scanner.py
15
15
  nabla/schema_resolver.py
16
16
  nabla/term.py
17
+ nabla/ui.py
17
18
  nabla/schemas/site_profile.schema.json
18
19
  nabla_cli.egg-info/PKG-INFO
19
20
  nabla_cli.egg-info/SOURCES.txt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes