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.
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/PKG-INFO +1 -1
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/__init__.py +1 -1
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/branding.py +15 -9
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/cli.py +76 -36
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/recorder.py +13 -5
- nabla_cli-0.4.0/nabla/ui.py +170 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/PKG-INFO +1 -1
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/SOURCES.txt +1 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/pyproject.toml +1 -1
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/README.md +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/__main__.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/config.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/contract.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/induce.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/profile.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/samplegen.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/scanner.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla/term.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/requires.txt +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/setup.cfg +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_cli_ux.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_config.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_induce.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_recorder.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_scanner.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.0}/tests/test_schema_resolver.py +0 -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
|
-
|
|
32
|
-
"
|
|
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:<
|
|
52
|
+
row.append(f" {logo_line:<14}", style=_BRAND_STYLE)
|
|
47
53
|
if tagline:
|
|
48
|
-
row.append(tagline, style="bold" if tagline
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
|
|
486
|
+
rail_end(_out, f"next: nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
|
|
459
487
|
else:
|
|
460
|
-
|
|
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
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
538
|
-
_out
|
|
539
|
-
_out
|
|
540
|
-
_out
|
|
541
|
-
_out
|
|
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
|
|
581
|
+
rail(_out, f"difficulty {dims}")
|
|
544
582
|
if len(examples) < 20:
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
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
|
-
|
|
470
|
-
|
|
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
|
|
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
|
+
)
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|