feat(cli): opt-in git_branch status-bar field (⎇ current branch)
MiniMax Code CLI 0.3.1 added a status-line segment showing the git branch for the current workspace. Hermes' status bar had no repo-awareness field. Adds `git_branch` to display.status_bar.fields (opt-in only — the default set never probes the filesystem). Reads .git/HEAD directly with a 5s per-directory TTL cache (no subprocess per repaint); follows gitdir: pointer files so worktrees/submodules resolve their private HEAD; a detached HEAD renders the abbreviated commit. Inspired by MiniMax Code CLI 0.3.1 changelog (agent.minimax.io/docs/changelog).
This commit is contained in:
@@ -208,6 +208,7 @@ class CLIStatusBarMixin:
|
||||
"battery_label": "",
|
||||
"battery_category": "dim",
|
||||
"focus_label": "", # /focus badge: the reduced-output mode is never invisible.
|
||||
"git_branch": "",
|
||||
"goal_active": False,
|
||||
"goal_turns_used": 0,
|
||||
"goal_max_turns": 0}
|
||||
@@ -220,6 +221,17 @@ class CLIStatusBarMixin:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Git branch (⎇) — opt-in via display.status_bar.fields, so the filesystem probe
|
||||
# (TTL-cached in status_bar_git) only runs when the user asked for the segment.
|
||||
try:
|
||||
_fields = self._get_status_bar_field_set()
|
||||
if _fields is not None and "git_branch" in _fields:
|
||||
from hermes_cli.status_bar_git import current_git_branch
|
||||
|
||||
snapshot["git_branch"] = current_git_branch()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Battery reads are memoised inside agent.battery, so per-repaint polling is cheap.
|
||||
if getattr(self, "_battery_visible", False):
|
||||
try:
|
||||
@@ -955,9 +967,9 @@ class CLIStatusBarMixin:
|
||||
``CLI_CONFIG``; no per-render YAML parse). ``None`` = not customized, show everything.
|
||||
|
||||
Fields: model, context_detail, context_pct, cache_hit, latency, tps, compressions,
|
||||
bg_tasks, bg_processes, bg_subagents, goal, duration, prompt_elapsed, idle_since,
|
||||
focus, yolo, stash, battery, title, total_tokens (opt-in only). Order is fixed; the
|
||||
config controls visibility only.
|
||||
bg_tasks, bg_processes, bg_subagents, goal, git_branch (opt-in only), duration,
|
||||
prompt_elapsed, idle_since, focus, yolo, stash, battery, title, total_tokens
|
||||
(opt-in only). Order is fixed; the config controls visibility only.
|
||||
"""
|
||||
from cli import CLI_CONFIG
|
||||
if hasattr(self, "_status_bar_field_set_cache"):
|
||||
@@ -1043,6 +1055,9 @@ class CLIStatusBarMixin:
|
||||
add_count("bg_subagents", "active_background_subagents", "⛓")
|
||||
if goal_segment:
|
||||
add("goal", _STRONG, goal_segment)
|
||||
git_branch = snapshot.get("git_branch") or ""
|
||||
if git_branch:
|
||||
add("git_branch", _DIM, f"⎇ {git_branch}")
|
||||
if not narrow:
|
||||
add("duration", _DIM, duration_label)
|
||||
if wide:
|
||||
|
||||
@@ -896,7 +896,8 @@ DEFAULT_CONFIG = {
|
||||
# CLI/TUI status bar fields. Non-empty = only listed fields show (built-in order kept,
|
||||
# config controls visibility not ordering); empty = default set. Available: model,
|
||||
# context_detail, context_pct, cache_hit, latency, tps, compressions, bg_tasks,
|
||||
# bg_processes, bg_subagents, goal, duration, prompt_elapsed, idle_since, focus, yolo,
|
||||
# bg_processes, bg_subagents, goal, git_branch (⎇ current branch, opt-in only), duration,
|
||||
# prompt_elapsed, idle_since, focus, yolo,
|
||||
# stash, battery, title, total_tokens (session Σ, opt-in only). Narrow terminals still drop
|
||||
# context_detail/prompt_elapsed/idle_since.
|
||||
"status_bar": {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Current git branch for the CLI status bar.
|
||||
|
||||
Reads ``.git/HEAD`` directly (no subprocess) so status-bar repaints stay cheap, with a
|
||||
short per-directory TTL cache. Worktrees and submodules (``.git`` as a ``gitdir:``
|
||||
pointer file) resolve to their private git dir, whose HEAD is per-worktree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_TTL_SECONDS = 5.0
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def _resolve_git_dir(start: Path) -> Optional[Path]:
|
||||
"""Nearest enclosing git dir for ``start``, following worktree pointer files."""
|
||||
for parent in (start, *start.parents):
|
||||
dotgit = parent / ".git"
|
||||
if dotgit.is_dir():
|
||||
return dotgit
|
||||
if dotgit.is_file():
|
||||
try:
|
||||
line = dotgit.read_text(encoding="utf-8", errors="replace").strip()
|
||||
except OSError:
|
||||
return None
|
||||
if line.startswith("gitdir:"):
|
||||
target = (parent / line.split(":", 1)[1].strip()).resolve()
|
||||
return target if target.is_dir() else None
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def current_git_branch(cwd: Optional[str] = None) -> str:
|
||||
"""Branch name for ``cwd`` (defaults to the process cwd); ``""`` outside a repo.
|
||||
|
||||
A detached HEAD renders as the abbreviated commit (``a1b2c3d…``). Results are
|
||||
cached ~5s per directory so per-repaint calls never re-walk the tree.
|
||||
"""
|
||||
try:
|
||||
base = Path(cwd or os.getcwd()).resolve()
|
||||
except OSError:
|
||||
return ""
|
||||
key = str(base)
|
||||
now = time.monotonic()
|
||||
hit = _cache.get(key)
|
||||
if hit and now - hit[0] < _TTL_SECONDS:
|
||||
return hit[1]
|
||||
label = ""
|
||||
git_dir = _resolve_git_dir(base)
|
||||
if git_dir is not None:
|
||||
try:
|
||||
head = (git_dir / "HEAD").read_text(encoding="utf-8", errors="replace").strip()
|
||||
except OSError:
|
||||
head = ""
|
||||
if head.startswith("ref:"):
|
||||
ref = head.split(":", 1)[1].strip()
|
||||
label = ref[len("refs/heads/"):] if ref.startswith("refs/heads/") else ref.rsplit("/", 1)[-1]
|
||||
elif head:
|
||||
label = f"{head[:8]}…"
|
||||
_cache[key] = (now, label)
|
||||
return label
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Invariant tests for the opt-in git-branch status-bar field."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from cli import HermesCLI
|
||||
from hermes_cli import status_bar_git
|
||||
from hermes_cli.status_bar_git import current_git_branch
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.model = "anthropic/claude-sonnet-4-20250514"
|
||||
cli_obj.session_start = datetime.now() - timedelta(minutes=3)
|
||||
cli_obj.conversation_history = [{"role": "user", "content": "hi"}]
|
||||
cli_obj.agent = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
def test_current_git_branch_reads_head_and_worktree_pointer(tmp_path):
|
||||
"""Branch resolves from .git/HEAD in a plain repo AND through a gitdir: pointer
|
||||
(worktree layout); a non-repo dir yields ''."""
|
||||
repo = tmp_path / "repo"
|
||||
(repo / ".git").mkdir(parents=True)
|
||||
(repo / ".git" / "HEAD").write_text("ref: refs/heads/minimax-inspired/status-bar-git-branch\n")
|
||||
status_bar_git._cache.clear()
|
||||
assert current_git_branch(str(repo)) == "minimax-inspired/status-bar-git-branch"
|
||||
|
||||
# Worktree: .git is a file pointing at a private git dir with its own HEAD.
|
||||
private = tmp_path / "gitdir-store"
|
||||
private.mkdir()
|
||||
(private / "HEAD").write_text("ref: refs/heads/feature-x\n")
|
||||
wt = tmp_path / "wt"
|
||||
wt.mkdir()
|
||||
(wt / ".git").write_text(f"gitdir: {private}\n")
|
||||
status_bar_git._cache.clear()
|
||||
assert current_git_branch(str(wt)) == "feature-x"
|
||||
|
||||
plain = tmp_path / "plain"
|
||||
plain.mkdir()
|
||||
status_bar_git._cache.clear()
|
||||
assert current_git_branch(str(plain)) == ""
|
||||
|
||||
|
||||
def test_git_branch_segment_is_opt_in(monkeypatch, tmp_path):
|
||||
"""The ⎇ segment renders only when 'git_branch' is in the configured field list —
|
||||
default field set (None) never probes or shows it."""
|
||||
repo = tmp_path / "repo"
|
||||
(repo / ".git").mkdir(parents=True)
|
||||
(repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
monkeypatch.chdir(repo)
|
||||
status_bar_git._cache.clear()
|
||||
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._status_bar_field_set_cache = None # default set
|
||||
snapshot = cli_obj._get_status_bar_snapshot()
|
||||
assert snapshot["git_branch"] == ""
|
||||
text = "".join(
|
||||
t for seg in cli_obj._status_bar_segments(
|
||||
snapshot, 120, None, False, styled=False) for _, t in seg)
|
||||
assert "⎇" not in text
|
||||
|
||||
cli_obj2 = _make_cli()
|
||||
fields = frozenset({"model", "git_branch"})
|
||||
cli_obj2._status_bar_field_set_cache = fields
|
||||
snapshot2 = cli_obj2._get_status_bar_snapshot()
|
||||
assert snapshot2["git_branch"] == "main"
|
||||
text2 = "".join(
|
||||
t for seg in cli_obj2._status_bar_segments(
|
||||
snapshot2, 120, fields, False, styled=False) for _, t in seg)
|
||||
assert "⎇ main" in text2
|
||||
@@ -2115,11 +2115,11 @@ display:
|
||||
fields: ["model", "duration", "total_tokens"] # visibility only; built-in order is preserved
|
||||
```
|
||||
|
||||
Supported fields: `model`, `context_detail` (used/total tokens), `context_pct` (percent + meter), `cache_hit` (prompt cache hit ratio — resets on model switch and compression), `latency` (rolling mean API latency, last 10 calls), `tps` (rolling output tokens/sec, last 10 calls), `compressions`, `bg_tasks`, `bg_processes`, `bg_subagents`, `goal`, `duration`, `prompt_elapsed`, `idle_since`, `focus`, `yolo`, `stash`, `battery`, `title` (right-aligned session badge), and `total_tokens` (session Σ — opt-in only, never shown by default).
|
||||
Supported fields: `model`, `context_detail` (used/total tokens), `context_pct` (percent + meter), `cache_hit` (prompt cache hit ratio — resets on model switch and compression), `latency` (rolling mean API latency, last 10 calls), `tps` (rolling output tokens/sec, last 10 calls), `compressions`, `bg_tasks`, `bg_processes`, `bg_subagents`, `goal`, `git_branch` (⎇ current git branch of the working directory — opt-in only, never shown by default; detached HEAD shows the abbreviated commit), `duration`, `prompt_elapsed`, `idle_since`, `focus`, `yolo`, `stash`, `battery`, `title` (right-aligned session badge), and `total_tokens` (session Σ — opt-in only, never shown by default).
|
||||
|
||||
Notes:
|
||||
|
||||
- An empty list (the default) keeps the standard set — everything except `total_tokens`.
|
||||
- An empty list (the default) keeps the standard set — everything except `total_tokens` and `git_branch`.
|
||||
- The config controls **visibility, not order**; fields render in their built-in positions.
|
||||
- Narrow terminals still drop wide-mode-only fields (`context_detail`, `cache_hit`, `latency`, `tps`, `prompt_elapsed`, `idle_since`) regardless of config (`cache_hit` also shows in the medium ≥52-col tier).
|
||||
- `latency`/`tps` stay hidden until API calls have been recorded (e.g. the Codex app-server backend reports no latency).
|
||||
|
||||
Reference in New Issue
Block a user