feat: add Braille spinner, shell completions, and TUI polish

- Add with_spinner context manager with cyan Braille animation for human mode.
- Wrap all query/execution commands in cli.py with loading spinners.
- Integrate shtab: auto-install shell completions during init for zsh/bash.
- Add `completion` subcommand for manual script generation.
- Fix stale output_format default in DEFAULT_CONFIG (json → tui).
- Add help descriptions to all second-level subcommands.
- Version 2.0.4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 19:11:40 +08:00
parent b857ea33f3
commit 536425e8ea
6 changed files with 257 additions and 79 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import argparse
import csv
import io
import json
@@ -10,11 +11,19 @@ import re
import shutil
import subprocess
import sys
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import asdict, dataclass, is_dataclass
from datetime import date, datetime
from pathlib import Path
from typing import Any
try:
import shtab
except Exception: # pragma: no cover
shtab = None # type: ignore[assignment]
@dataclass(frozen=True)
class RuntimePaths:
@@ -345,6 +354,26 @@ def _render_tui(payload: Any) -> None:
print(f" {line}")
return
if "created_or_updated" in payload:
print(f"\n{_BOLD}{_CYAN} INITIALIZED {_RESET}")
print(f" Root: {payload.get('root', '')}")
print(f" Config: {payload.get('config_file', '')}")
print(f" Env: {payload.get('env_file', '')}")
print(f" Logs: {payload.get('logs_dir', '')}")
files = payload.get("created_or_updated", [])
if files:
action = "overwritten" if payload.get("force") else "created"
print(f" Files {action}: {', '.join(files)}")
comp = payload.get("completion", {})
if comp.get("installed"):
print(f"\n {_GREEN}{_RESET} Shell completions installed for {comp.get('shell', '')}")
print(f" Path: {comp.get('path', '')}")
if comp.get("hint"):
print(f" Hint: {comp.get('hint', '')}")
elif comp.get("reason"):
print(f"\n Shell completions: {comp.get('reason', '')}")
return
# Generic fallback for single-list payloads
if len(payload) == 1:
key, value = next(iter(payload.items()))
@@ -370,3 +399,114 @@ def print_output(payload: Any, *, agent: bool = False) -> None:
print_json(payload)
else:
_render_tui(payload)
# ---------------------------------------------------------------------------
# Spinner / loading animation
# ---------------------------------------------------------------------------
_SPINNER_FRAMES = ["", "", "", "", "", "", "", "", "", ""]
class _SpinnerThread(threading.Thread):
def __init__(self, message: str, interval: float = 0.08) -> None:
super().__init__(daemon=True)
self.message = message
self.interval = interval
self._stop_event = threading.Event()
def run(self) -> None:
i = 0
while not self._stop_event.is_set():
frame = _SPINNER_FRAMES[i % len(_SPINNER_FRAMES)]
sys.stdout.write(f"\r{_CYAN}{frame}{_RESET} {self.message} ")
sys.stdout.flush()
self._stop_event.wait(self.interval)
i += 1
def stop(self) -> None:
self._stop_event.set()
self.join()
sys.stdout.write("\r\033[K")
sys.stdout.flush()
@contextmanager
def with_spinner(message: str, *, enabled: bool = True) -> Iterator[None]:
if not enabled or not sys.stdout.isatty():
yield
return
spinner = _SpinnerThread(message)
spinner.start()
try:
yield
finally:
spinner.stop()
def _detect_shell() -> str:
shell = os.getenv("SHELL", "")
if "zsh" in shell:
return "zsh"
if "bash" in shell:
return "bash"
return ""
def _zshrc_path() -> Path:
return Path.home() / ".zshrc"
def _bashrc_path() -> Path:
return Path.home() / ".bashrc"
def _rc_contains(rc_path: Path, snippet: str) -> bool:
if not rc_path.exists():
return False
return snippet in rc_path.read_text(encoding="utf-8")
def install_shell_completion(parser: argparse.ArgumentParser) -> dict[str, Any]:
if shtab is None:
return {"shell": None, "installed": False, "reason": "shtab is not installed"}
shell = _detect_shell()
if not shell:
return {"shell": None, "installed": False, "reason": "unable to detect shell from $SHELL"}
script = shtab.complete(parser, shell=shell, preamble="")
installed_path: Path | None = None
hint: str | None = None
if shell == "zsh":
comp_dir = Path.home() / ".zsh" / "completions"
comp_dir.mkdir(parents=True, exist_ok=True)
installed_path = comp_dir / "_coinhunter"
installed_path.write_text(script, encoding="utf-8")
rc_path = _zshrc_path()
fpath_line = "fpath+=(~/.zsh/completions)"
if not _rc_contains(rc_path, fpath_line):
rc_path.write_text(fpath_line + "\n" + rc_path.read_text(encoding="utf-8") if rc_path.exists() else fpath_line + "\n", encoding="utf-8")
hint = "Added fpath+=(~/.zsh/completions) to ~/.zshrc; restart your terminal or run 'compinit'"
else:
hint = "Run 'compinit' or restart your terminal to activate completions"
elif shell == "bash":
comp_dir = Path.home() / ".local" / "share" / "bash-completion" / "completions"
comp_dir.mkdir(parents=True, exist_ok=True)
installed_path = comp_dir / "coinhunter"
installed_path.write_text(script, encoding="utf-8")
rc_path = _bashrc_path()
source_line = '[[ -r "~/.local/share/bash-completion/completions/coinhunter" ]] && . "~/.local/share/bash-completion/completions/coinhunter"'
if not _rc_contains(rc_path, source_line):
rc_path.write_text(source_line + "\n" + rc_path.read_text(encoding="utf-8") if rc_path.exists() else source_line + "\n", encoding="utf-8")
hint = "Added bash completion source line to ~/.bashrc; restart your terminal"
else:
hint = "Restart your terminal or source ~/.bashrc to activate completions"
return {
"shell": shell,
"installed": True,
"path": str(installed_path) if installed_path else None,
"hint": hint,
}