From fc7637e8c408a132e07cbb17437e0592e25ab039 Mon Sep 17 00:00:00 2001 From: Asher Feldman <59994+asher@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:53:23 -0700 Subject: [PATCH 1/2] fix(cli): help flags always win, richer condensed help, choices/theme/profile tab completion, quiet ^C exit sweeps --- gmlx/chat.py | 7 ++++--- gmlx/cli.py | 15 ++++++++++++++- gmlx/completion.py | 31 +++++++++++++++++++++++++++++++ gmlx/decode_feeder.py | 10 ++++++++-- gmlx/pagecache.py | 7 ++++++- tests/test_cli_umbrella.py | 17 +++++++++++++++++ tests/test_completion.py | 17 +++++++++++++++++ 7 files changed, 97 insertions(+), 7 deletions(-) diff --git a/gmlx/chat.py b/gmlx/chat.py index 8766764..54e2570 100644 --- a/gmlx/chat.py +++ b/gmlx/chat.py @@ -98,9 +98,10 @@ def _build_parser(prog: str = "gmlx chat") -> argparse.ArgumentParser: ) from .cli import add_condensed_help add_condensed_help(ap, ( - "gguf", "--assistant", "--system-prompt", "--reasoning", - "--thinking", "--mmproj", "--max-tokens", "--temp", "--resume", - "--theme", "--verbose", + "gguf", "--assistant", "--config", "--profile", "--system-prompt", + "--reasoning", "--thinking", "--mmproj", "--max-tokens", "--temp", + "--top-p", "--min-p", "--max-kv-size", "--stream-experts", + "--resume", "--theme", "--verbose", )) ap.add_argument( "gguf", nargs="?", default=None, diff --git a/gmlx/cli.py b/gmlx/cli.py index 924240b..f02540a 100644 --- a/gmlx/cli.py +++ b/gmlx/cli.py @@ -695,8 +695,9 @@ def _build_parser(prog: str = "gmlx run") -> argparse.ArgumentParser: ) add_condensed_help(ap, ( "gguf", "--prompt", "--prompt-file", "--max-tokens", "--temp", + "--top-p", "--min-p", "--stop", "--config", "--profile", "--system-prompt", "--reasoning", "--thinking", "--mmproj", - "--image", "--stream-experts", "--verbose", + "--image", "--max-kv-size", "--stream-experts", "--verbose", )) ap.add_argument("gguf", help="Path to the GGUF file (sharded ok).") prompt_group = ap.add_mutually_exclusive_group() @@ -2204,6 +2205,18 @@ def umbrella_main(argv: list[str] | None = None) -> int: print(f"error: unknown command {verb!r}.\n", file=sys.stderr) _print_umbrella_help(prog) return 2 + # Help always wins: argparse lets a value-taking flag swallow a following + # help token (`chat --thinking --help` dies with "expected one argument" + # instead of printing help), so hoist a bare help flag to the front. Safe: + # argparse rejects flag-like option values anyway, so a bare help token + # can never be a legitimate value. --help-all only where a verb defines it. + for tok in rest: + if tok == "--": + break + if tok in ("-h", "--help") or ( + tok == "--help-all" and verb in ("run", "chat")): + rest = [tok] + break if verb != "doctor": # doctor must run on a broken env to diagnose it from .upstream_seams import check_upstream_versions try: diff --git a/gmlx/completion.py b/gmlx/completion.py index 21f3d23..aee4408 100644 --- a/gmlx/completion.py +++ b/gmlx/completion.py @@ -125,6 +125,31 @@ def _capture_help(verb: str) -> str: return buf.getvalue() +def _choice_values(metavar: str) -> list[str]: + """The values of a ``{a,b,c}`` choices metavar, else ``[]``.""" + m = re.fullmatch(r"\{([^{}]+)\}", metavar or "") + if not m: + return [] + return [v.strip() for v in m.group(1).split(",") if v.strip()] + + +def _named_value_candidates(flag: str) -> list[str]: + """Value candidates for flags whose metavar hides an enumerable set + (themes, sampling profiles).""" + try: + if flag == "--theme": + from .theme import list_themes + + return [f"{t}\tcolor theme" for t in list_themes()] + if flag == "--profile": + from .profiles import builtin_intents + + return [f"{i}\tbuilt-in intent" for i in sorted(builtin_intents())] + except Exception: # noqa: BLE001 - value candidates are best-effort + return [] + return [] + + def _is_pathish(metavar: str) -> bool: mv = (metavar or "").upper() return any(k in mv for k in ("PATH", "FILE", "DIR")) @@ -293,8 +318,14 @@ def _complete(argv: list[str]) -> list[str]: if prev.startswith("-") and "=" not in prev: opt = _option_for(verb, prev) if opt is not None and opt[1]: # the previous flag wants a value + choices = _choice_values(opt[1]) + if choices: # a {on,off,...} choices flag + return choices if _is_pathish(opt[1]): return ["::files"] + named = _named_value_candidates(opt[0]) + if named: + return named # An endpoint flag (--host/--port/--url/--base-url) completes from the # servers currently running; any other value flag (a temperature, a # token count) has nothing to enumerate. diff --git a/gmlx/decode_feeder.py b/gmlx/decode_feeder.py index c45fb7c..4781dd0 100644 --- a/gmlx/decode_feeder.py +++ b/gmlx/decode_feeder.py @@ -1397,7 +1397,7 @@ def _print_wedges(self) -> None: def __del__(self): try: self.close() - except Exception: + except BaseException: # noqa: BLE001 - incl. ^C during interpreter exit pass @@ -1412,8 +1412,14 @@ def _register_exit_close(feeder) -> None: def _close_at_exit(): f = wref() - if f is not None: + if f is None: + return + try: f.close() + except KeyboardInterrupt: + # ^C while already exiting: skip the rest of the cleanup + # (munlock/stat printing) - the OS reclaims it all anyway. + pass atexit.register(_close_at_exit) diff --git a/gmlx/pagecache.py b/gmlx/pagecache.py index f3cc43a..6b372df 100644 --- a/gmlx/pagecache.py +++ b/gmlx/pagecache.py @@ -138,7 +138,12 @@ def _exit_sweep() -> None: for group in _groups: paths.extend(p for p in group if p not in paths) del _groups[:] - release_file_cache(paths, log=print if _log_release else None) + try: + release_file_cache(paths, log=print if _log_release else None) + except KeyboardInterrupt: + # ^C while already exiting: abandon the sweep quietly - the pages + # just stay cached, which only costs the next cold load. + pass def register_streaming_release(paths) -> None: diff --git a/tests/test_cli_umbrella.py b/tests/test_cli_umbrella.py index e444d0b..191a7b8 100644 --- a/tests/test_cli_umbrella.py +++ b/tests/test_cli_umbrella.py @@ -191,3 +191,20 @@ def test_chat_help_all_is_complete(capsys): chat.cmd_chat(["--help-all"]) out = capsys.readouterr().out assert "--logit-bias" in out and "--kv-bits" in out + + +# Help always wins: a value-taking flag must not swallow a following help +# token (`chat --thinking --help` used to die with "expected one argument"). +def test_help_after_value_flag_is_hoisted(routes): + assert cli.umbrella_main(["chat", "--thinking", "--help"]) == 0 + assert routes["chat"] == ["--help"] + assert cli.umbrella_main(["run", "--profile", "--help-all"]) == 0 + assert routes["run"] == ["--help-all"] + assert cli.umbrella_main(["serve", "model.gguf", "--config", "-h"]) == 0 + assert routes["server"] == ["-h"] + + +def test_help_hoist_stops_at_double_dash(routes): + # Past a `--` separator nothing is a flag; the argv passes through intact. + assert cli.umbrella_main(["rm", "old-model", "--", "--help"]) == 0 + assert routes["rm"] == ["old-model", "--", "--help"] diff --git a/tests/test_completion.py b/tests/test_completion.py index f22ca9d..0bf74fd 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -73,6 +73,23 @@ def test_non_path_flag_value_offers_nothing(): assert completion._complete(["run", "--temp", ""]) == [] +def test_choices_flag_value_completes_choices(): + assert completion._complete(["chat", "--thinking", ""]) == \ + ["on", "off", "adaptive"] + assert completion._complete(["run", "--reasoning", ""]) == \ + ["show", "hide", "raw"] + + +def test_theme_flag_value_completes_themes(): + vals = _vals(completion._complete(["chat", "--theme", ""])) + assert "dark" in vals and "light" in vals + + +def test_profile_flag_value_completes_intents(): + vals = _vals(completion._complete(["chat", "--profile", ""])) + assert "coding" in vals and "reasoning-high" in vals + + def _write_cfg(tmp_path): cfg = tmp_path / "models.yaml" cfg.write_text(textwrap.dedent(""" From 43a2e6c8d0c07c3ba01da4aaedcc0856b87f94f4 Mon Sep 17 00:00:00 2001 From: Asher Feldman <59994+asher@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:54:00 -0700 Subject: [PATCH 2/2] docs: changelog entry for CLI help/completion/exit UX fixes --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1457770..62d1222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- CLI polish: `--help`/`--help-all` now win even after a value-taking flag, + the short `-h` pages gained the typical sampling/config/streaming flags, + tab completion offers flag values (choices, themes, profiles). + ## [0.2.0] - 2026-08-02 ### Added @@ -71,7 +77,6 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). (thinking strip, tool-call re-serialization): decode-time snapshots retain the reply up to the divergence point (`GMLX_APC_DECODE_CKPT`, default 512 generated tokens; `0` off). - - `run`/`chat --thinking on|off|adaptive` and `--reasoning-effort LEVEL`: first-class switches for thinking models. The chat template picks the spelling: MiniMax's three-state `thinking_mode`