diff --git a/cli.py b/cli.py index c7a5e32498385..36497e800880a 100644 --- a/cli.py +++ b/cli.py @@ -3989,6 +3989,23 @@ def _get_voice_status_fragments(self, width: Optional[int] = None): cont = " | Continuous" if self._voice_continuous else "" return [("class:voice-status", f" ๐ŸŽค Voice mode{tts}{cont} โ€” {label} to record ")] + def _get_status_bar_field_set(self) -> Optional[frozenset]: + """Return the set of allowed status-bar fields from config. + + Returns ``None`` when the user has not customized the bar (use + built-in defaults). Returns a ``frozenset`` of field names when + ``display.status_bar.fields`` is a non-empty list. + """ + try: + from hermes_cli.config import load_config + cfg = load_config() + fields = (cfg.get("display") or {}).get("status_bar", {}).get("fields") + if isinstance(fields, list) and fields: + return frozenset(str(f) for f in fields) + except Exception: + pass + return None + def _build_status_bar_text(self, width: Optional[int] = None) -> str: """Return a compact one-line session status string for the TUI footer.""" try: @@ -4000,51 +4017,75 @@ def _build_status_bar_text(self, width: Optional[int] = None) -> str: duration_label = snapshot["duration"] yolo_active = self._is_session_yolo_active() + fields = self._get_status_bar_field_set() + + def _ok(name: str) -> bool: + return fields is None or name in fields + if width < 52: - text = f"โš• {snapshot['model_short']} ยท {duration_label}" - if yolo_active: - text += " ยท โš  YOLO" - return self._trim_status_bar_text(text, width) + parts = [] + if _ok("model"): + parts.append(f"โš• {snapshot['model_short']}") + if _ok("duration"): + parts.append(duration_label) + if yolo_active and _ok("yolo"): + parts.append("โš  YOLO") + return self._trim_status_bar_text(" ยท ".join(parts) if parts else f"โš• {snapshot['model_short']}", width) if width < 76: - parts = [f"โš• {snapshot['model_short']}", percent_label] + parts = [] + if _ok("model"): + parts.append(f"โš• {snapshot['model_short']}") + if _ok("context_pct"): + parts.append(percent_label) compressions = snapshot.get("compressions", 0) - if compressions: + if compressions and _ok("compressions"): parts.append(f"๐Ÿ—œ๏ธ {compressions}") bg_count = snapshot.get("active_background_tasks", 0) - if bg_count: + if bg_count and _ok("bg_tasks"): parts.append(f"โ–ถ {bg_count}") bg_proc_count = snapshot.get("active_background_processes", 0) - if bg_proc_count: + if bg_proc_count and _ok("bg_processes"): parts.append(f"โš™ {bg_proc_count}") - parts.append(duration_label) - if yolo_active: + if _ok("duration"): + parts.append(duration_label) + if yolo_active and _ok("yolo"): parts.append("โš  YOLO") - return self._trim_status_bar_text(" ยท ".join(parts), width) - - if snapshot["context_length"]: - ctx_total = _format_context_length(snapshot["context_length"]) - ctx_used = format_token_count_compact(snapshot["context_tokens"]) - context_label = f"{ctx_used}/{ctx_total}" - else: - context_label = "ctx --" - + return self._trim_status_bar_text(" ยท ".join(parts) if parts else f"โš• {snapshot['model_short']}", width) + + parts = [] + if _ok("model"): + parts.append(f"โš• {snapshot['model_short']}") + if _ok("context_detail"): + if snapshot["context_length"]: + ctx_total = _format_context_length(snapshot["context_length"]) + ctx_used = format_token_count_compact(snapshot["context_tokens"]) + context_label = f"{ctx_used}/{ctx_total}" + else: + context_label = "ctx --" + parts.append(context_label) + if _ok("context_pct"): + parts.append(percent_label) compressions = snapshot.get("compressions", 0) - parts = [f"โš• {snapshot['model_short']}", context_label, percent_label] - if compressions: + if compressions and _ok("compressions"): parts.append(f"๐Ÿ—œ๏ธ {compressions}") bg_count = snapshot.get("active_background_tasks", 0) - if bg_count: + if bg_count and _ok("bg_tasks"): parts.append(f"โ–ถ {bg_count}") bg_proc_count = snapshot.get("active_background_processes", 0) - if bg_proc_count: + if bg_proc_count and _ok("bg_processes"): parts.append(f"โš™ {bg_proc_count}") - parts.append(duration_label) + if _ok("duration"): + parts.append(duration_label) prompt_elapsed = snapshot.get("prompt_elapsed") - if prompt_elapsed: + if prompt_elapsed and _ok("prompt_elapsed"): parts.append(prompt_elapsed) - if yolo_active: + if yolo_active and _ok("yolo"): parts.append("โš  YOLO") - return self._trim_status_bar_text(" โ”‚ ".join(parts), width) + total_tokens = snapshot.get("session_total_tokens", 0) + # Only show total_tokens when explicitly requested via config + if total_tokens and fields is not None and _ok("total_tokens"): + parts.append(f"ฮฃ{format_token_count_compact(total_tokens)}") + return self._trim_status_bar_text(" โ”‚ ".join(parts) if parts else f"โš• {snapshot['model_short']}", width) except Exception: return f"โš• {self.model if getattr(self, 'model', None) else 'Hermes'}" @@ -4053,100 +4094,95 @@ def _get_status_bar_fragments(self): return [] try: snapshot = self._get_status_bar_snapshot() - # Use prompt_toolkit's own terminal width when running inside the - # TUI โ€” shutil.get_terminal_size() can return stale or fallback - # values (especially on SSH) that differ from what prompt_toolkit - # actually renders, causing the fragments to overflow to a second - # line and produce duplicated status bar rows over long sessions. width = self._get_tui_terminal_width() duration_label = snapshot["duration"] yolo_active = self._is_session_yolo_active() + fields = self._get_status_bar_field_set() + + def _ok(name: str) -> bool: + return fields is None or name in fields + + def _sep(ch: str = " ยท ") -> tuple: + return ("class:status-bar-dim", ch) + + frags = [] if width < 52: - frags = [ - ("class:status-bar", " โš• "), - ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " ยท "), - ("class:status-bar-dim", duration_label), - ] - if yolo_active: - frags.append(("class:status-bar-dim", " ยท ")) + if _ok("model"): + frags.append(("class:status-bar", " โš• ")) + frags.append(("class:status-bar-strong", snapshot["model_short"])) + if _ok("duration"): + if frags: + frags.append(_sep(" ยท ")) + frags.append(("class:status-bar-dim", duration_label)) + if yolo_active and _ok("yolo"): + if frags: + frags.append(_sep(" ยท ")) frags.append(("class:status-bar-yolo", "โš  YOLO")) + if not frags: + frags = [("class:status-bar", " โš• "), ("class:status-bar-strong", snapshot["model_short"])] frags.append(("class:status-bar", " ")) else: percent = snapshot["context_percent"] percent_label = f"{percent}%" if percent is not None else "--" - if width < 76: - compressions = snapshot.get("compressions", 0) - bg_count = snapshot.get("active_background_tasks", 0) - bg_proc_count = snapshot.get("active_background_processes", 0) - frags = [ - ("class:status-bar", " โš• "), - ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " ยท "), - (self._status_bar_context_style(percent), percent_label), - ] - if compressions: - frags.append(("class:status-bar-dim", " ยท ")) - frags.append((self._compression_count_style(compressions), f"๐Ÿ—œ๏ธ {compressions}")) - if bg_count: - frags.append(("class:status-bar-dim", " ยท ")) - frags.append(("class:status-bar-strong", f"โ–ถ {bg_count}")) - if bg_proc_count: - frags.append(("class:status-bar-dim", " ยท ")) - frags.append(("class:status-bar-strong", f"โš™ {bg_proc_count}")) - frags.extend([ - ("class:status-bar-dim", " ยท "), - ("class:status-bar-dim", duration_label), - ]) - if yolo_active: - frags.append(("class:status-bar-dim", " ยท ")) - frags.append(("class:status-bar-yolo", "โš  YOLO")) - frags.append(("class:status-bar", " ")) - else: + sep_ch = " ยท " if width < 76 else " โ”‚ " + + if _ok("model"): + frags.append(("class:status-bar", " โš• ")) + frags.append(("class:status-bar-strong", snapshot["model_short"])) + # Wide mode: context_detail (label) + bar + percent + if width >= 76 and _ok("context_detail"): + if frags: + frags.append(_sep(sep_ch)) if snapshot["context_length"]: ctx_total = _format_context_length(snapshot["context_length"]) ctx_used = format_token_count_compact(snapshot["context_tokens"]) context_label = f"{ctx_used}/{ctx_total}" else: context_label = "ctx --" - - bar_style = self._status_bar_context_style(percent) - compressions = snapshot.get("compressions", 0) - bg_count = snapshot.get("active_background_tasks", 0) - bg_proc_count = snapshot.get("active_background_processes", 0) - frags = [ - ("class:status-bar", " โš• "), - ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " โ”‚ "), - ("class:status-bar-dim", context_label), - ("class:status-bar-dim", " โ”‚ "), - (bar_style, self._build_context_bar(percent)), - ("class:status-bar-dim", " "), - (bar_style, percent_label), - ] - if compressions: - frags.append(("class:status-bar-dim", " โ”‚ ")) - frags.append((self._compression_count_style(compressions), f"๐Ÿ—œ๏ธ {compressions}")) - if bg_count: - frags.append(("class:status-bar-dim", " โ”‚ ")) - frags.append(("class:status-bar-strong", f"โ–ถ {bg_count}")) - if bg_proc_count: - frags.append(("class:status-bar-dim", " โ”‚ ")) - frags.append(("class:status-bar-strong", f"โš™ {bg_proc_count}")) - frags.extend([ - ("class:status-bar-dim", " โ”‚ "), - ("class:status-bar-dim", duration_label), - ]) - # Position 7: per-prompt elapsed timer (live or frozen) - prompt_elapsed = snapshot.get("prompt_elapsed") - if prompt_elapsed: - frags.append(("class:status-bar-dim", " โ”‚ ")) - frags.append(("class:status-bar-dim", prompt_elapsed)) - if yolo_active: - frags.append(("class:status-bar-dim", " โ”‚ ")) - frags.append(("class:status-bar-yolo", "โš  YOLO")) - frags.append(("class:status-bar", " ")) + frags.append(("class:status-bar-dim", context_label)) + if _ok("context_pct"): + bar_style = self._status_bar_context_style(percent) + frags.append(_sep(sep_ch)) + frags.append((bar_style, self._build_context_bar(percent))) + frags.append(("class:status-bar-dim", " ")) + frags.append((bar_style, percent_label)) + elif _ok("context_pct"): + if frags: + frags.append(_sep(sep_ch)) + frags.append((self._status_bar_context_style(percent), percent_label)) + compressions = snapshot.get("compressions", 0) + if compressions and _ok("compressions"): + frags.append(_sep(sep_ch)) + frags.append((self._compression_count_style(compressions), f"๐Ÿ—œ๏ธ {compressions}")) + bg_count = snapshot.get("active_background_tasks", 0) + if bg_count and _ok("bg_tasks"): + frags.append(_sep(sep_ch)) + frags.append(("class:status-bar-strong", f"โ–ถ {bg_count}")) + bg_proc_count = snapshot.get("active_background_processes", 0) + if bg_proc_count and _ok("bg_processes"): + frags.append(_sep(sep_ch)) + frags.append(("class:status-bar-strong", f"โš™ {bg_proc_count}")) + if _ok("duration"): + frags.append(_sep(sep_ch)) + frags.append(("class:status-bar-dim", duration_label)) + prompt_elapsed = snapshot.get("prompt_elapsed") + if prompt_elapsed and _ok("prompt_elapsed"): + frags.append(_sep(sep_ch)) + frags.append(("class:status-bar-dim", prompt_elapsed)) + if yolo_active and _ok("yolo"): + frags.append(_sep(sep_ch)) + frags.append(("class:status-bar-yolo", "โš  YOLO")) + total_tokens = snapshot.get("session_total_tokens", 0) + # Only show total_tokens when explicitly requested via config + # (too wide for default layout in fragment mode) + if total_tokens and fields is not None and _ok("total_tokens"): + frags.append(_sep(sep_ch)) + frags.append(("class:status-bar-dim", f"ฮฃ{format_token_count_compact(total_tokens)}")) + + if not frags: + frags = [("class:status-bar", " โš• "), ("class:status-bar-strong", snapshot["model_short"])] + frags.append(("class:status-bar", " ")) total_width = sum(self._status_bar_display_width(text) for _, text in frags) if total_width > width: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index fc98998c2005c..6849604eb3e3b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1454,6 +1454,15 @@ def _ensure_hermes_home_managed(home: Path): "enabled": False, "fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide }, + # CLI interactive status bar field customization. When set, only the + # listed fields appear (in order). Omit to keep the default set. + # Available: model, context_pct, context_detail, compressions, + # bg_tasks, bg_processes, duration, prompt_elapsed, yolo, total_tokens + # Narrow terminals (<76 cols) automatically drop context_detail and + # compressions regardless of this setting. + "status_bar": { + "fields": [], # empty = use built-in defaults (all fields) + }, "copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled" }, diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 47a78c5704433..5cc03376c6690 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -676,3 +676,157 @@ def test_explicit_width_skips_pt_lookup(self): mock_get_app.assert_not_called() mock_shutil.assert_not_called() assert len(text) > 0 + + +class TestStatusBarFieldConfig: + """Tests for display.status_bar.fields config customization.""" + + def _cli_with_fields(self, fields, width=120): + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_230, + completion_tokens=2_220, + total_tokens=12_450, + api_calls=7, + context_tokens=12_450, + context_length=200_000, + compressions=7, + ) + with patch("hermes_cli.config.load_config", return_value={ + "display": {"status_bar": {"fields": fields}} + }): + text = cli_obj._build_status_bar_text(width=width) + return text + + def test_default_fields_show_all(self): + """With empty fields list, all default fields appear.""" + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_230, + completion_tokens=2_220, + total_tokens=12_450, + api_calls=7, + context_tokens=12_450, + context_length=200_000, + compressions=7, + ) + text = cli_obj._build_status_bar_text(width=120) + assert "claude-sonnet-4-20250514" in text + assert "12.4K/200K" in text + assert "๐Ÿ—œ๏ธ" in text + assert "15m" in text + + def test_only_model_and_duration(self): + """Config with only model and duration hides context and compressions.""" + text = self._cli_with_fields(["model", "duration"]) + assert "claude-sonnet-4-20250514" in text + assert "15m" in text + assert "12.4K/200K" not in text + assert "๐Ÿ—œ๏ธ" not in text + assert "%" not in text + + def test_only_model(self): + """Config with only model shows just the model name.""" + text = self._cli_with_fields(["model"]) + assert "claude-sonnet-4-20250514" in text + assert "15m" not in text + assert "12.4K/200K" not in text + + def test_context_pct_only(self): + """Config with only context_pct shows just the percentage.""" + text = self._cli_with_fields(["context_pct"]) + assert "6%" in text + assert "claude-sonnet-4-20250514" not in text + + def test_compressions_only(self): + """Config with only compressions shows just the compression count.""" + text = self._cli_with_fields(["compressions"]) + assert "๐Ÿ—œ๏ธ 7" in text + assert "claude-sonnet-4-20250514" not in text + + def test_total_tokens_when_explicitly_requested(self): + """total_tokens appears only when explicitly in config.""" + text = self._cli_with_fields(["model", "total_tokens"]) + assert "ฮฃ12.4K" in text + assert "claude-sonnet-4-20250514" in text + + def test_total_tokens_hidden_by_default(self): + """total_tokens does NOT appear with default (empty) fields.""" + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_230, + completion_tokens=2_220, + total_tokens=12_450, + api_calls=7, + context_tokens=12_450, + context_length=200_000, + compressions=7, + ) + text = cli_obj._build_status_bar_text(width=120) + assert "ฮฃ" not in text + + def test_narrow_terminal_drops_context_detail(self): + """Narrow terminal (<76) ignores context_detail even if configured.""" + text = self._cli_with_fields(["model", "context_detail", "duration"], width=60) + assert "claude-sonnet-4-20250514" in text + assert "15m" in text + # context_detail is wide-mode only + assert "12.4K/200K" not in text + + def test_fragments_respect_field_config(self): + """Fragments method also respects field config.""" + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_230, + completion_tokens=2_220, + total_tokens=12_450, + api_calls=7, + context_tokens=12_450, + context_length=200_000, + compressions=7, + ) + cli_obj._status_bar_visible = True + + with patch("hermes_cli.config.load_config", return_value={ + "display": {"status_bar": {"fields": ["model", "duration"]}} + }): + frags = cli_obj._get_status_bar_fragments() + + frag_texts = [text for _, text in frags] + assert any("claude-sonnet-4-20250514" in t for t in frag_texts) + assert any("15m" in t for t in frag_texts) + assert not any("๐Ÿ—œ๏ธ" in t for t in frag_texts) + assert not any("12.4K" in t for t in frag_texts) + + def test_field_order_is_fixed(self): + """Fields always appear in a fixed order regardless of config order. + + The config controls *which* fields are visible, not their order. + Model always comes first as the anchor. + """ + text = self._cli_with_fields(["duration", "model", "compressions"]) + # model always first, then compressions, then duration + model_pos = text.find("claude-sonnet-4-20250514") + comp_pos = text.find("๐Ÿ—œ๏ธ") + dur_pos = text.find("15m") + assert model_pos < comp_pos < dur_pos + + def test_empty_config_uses_defaults(self): + """Empty status_bar config dict uses all defaults.""" + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_230, + completion_tokens=2_220, + total_tokens=12_450, + api_calls=7, + context_tokens=12_450, + context_length=200_000, + compressions=7, + ) + with patch("hermes_cli.config.load_config", return_value={ + "display": {"status_bar": {}} + }): + text = cli_obj._build_status_bar_text(width=120) + assert "claude-sonnet-4-20250514" in text + assert "12.4K/200K" in text + assert "๐Ÿ—œ๏ธ" in text