diff --git a/cli.py b/cli.py index f85f8405f2..09cdf2e171 100644 --- a/cli.py +++ b/cli.py @@ -7170,6 +7170,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): return f"⊙ goal {used}/{max_turns}" return "⊙ goal" + def _get_status_bar_field_set(self) -> Optional[frozenset]: + """Return the set of visible status-bar fields from config. + + Reads ``display.status_bar.fields`` from the module-level + ``CLI_CONFIG`` (no per-render YAML parse — the status bar repaints + every frame). Returns ``None`` when the user has not customized the + bar (use built-in defaults, i.e. show everything), or a + ``frozenset`` of field names when the list is non-empty. + + Available fields: model, context_detail, context_pct, compressions, + bg_tasks, bg_processes, bg_subagents, goal, duration, + prompt_elapsed, idle_since, focus, yolo, total_tokens. + ``total_tokens`` is opt-in only (never shown by default). + The field order is fixed; the config controls visibility only. + """ + if hasattr(self, "_status_bar_field_set_cache"): + return self._status_bar_field_set_cache + result = None + try: + display = CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else None + status_bar = (display or {}).get("status_bar") if isinstance(display, dict) else None + fields = status_bar.get("fields") if isinstance(status_bar, dict) else None + if isinstance(fields, list) and fields: + result = frozenset(str(f) for f in fields) + except Exception: + result = None + self._status_bar_field_set_cache = result + return result + def _build_status_bar_text(self, width: Optional[int] = None) -> str: """Return a compact one-line session status string for the TUI footer.""" try: @@ -7186,75 +7215,109 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): yolo_active = self._is_session_yolo_active() goal_segment = self._status_bar_goal_segment(snapshot) + field_set = self._get_status_bar_field_set() + + def _ok(name: str) -> bool: + return field_set is None or name in field_set + + if not _ok("goal"): + goal_segment = "" + if not _ok("focus"): + focus_label = "" if width < 52: - text = f"{battery_prefix}⚕ {snapshot['model_short']} · {duration_label}" + segs = [] + if _ok("model"): + segs.append(f"⚕ {snapshot['model_short']}") + if _ok("duration"): + segs.append(duration_label) if goal_segment: - text += f" · {goal_segment}" + segs.append(goal_segment) if focus_label: - text += f" · {focus_label}" - if yolo_active: - text += " · ⚠ YOLO" + segs.append(focus_label) + if yolo_active and _ok("yolo"): + segs.append("⚠ YOLO") + text = battery_prefix + " · ".join(segs) if segs else f"{battery_prefix}⚕ {snapshot['model_short']}" return self._right_align_status_title(text, session_title, 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) if battery_label: parts.insert(0, battery_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}") bg_subagent_count = snapshot.get("active_background_subagents", 0) - if bg_subagent_count: + if bg_subagent_count and _ok("bg_subagents"): parts.append(f"⛓ {bg_subagent_count}") if goal_segment: parts.append(goal_segment) - parts.append(duration_label) + if _ok("duration"): + parts.append(duration_label) if focus_label: parts.append(focus_label) - if yolo_active: + if yolo_active and _ok("yolo"): parts.append("⚠ YOLO") + if not parts: + parts = [f"⚕ {snapshot['model_short']}"] return self._right_align_status_title(" · ".join(parts), session_title, 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 --" - - compressions = snapshot.get("compressions", 0) - parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label] + 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) if battery_label: parts.insert(0, battery_label) - if compressions: + compressions = snapshot.get("compressions", 0) + 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}") bg_subagent_count = snapshot.get("active_background_subagents", 0) - if bg_subagent_count: + if bg_subagent_count and _ok("bg_subagents"): parts.append(f"⛓ {bg_subagent_count}") if goal_segment: parts.append(goal_segment) - 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) idle_since = snapshot.get("idle_since") - if idle_since: + if idle_since and _ok("idle_since"): parts.append(idle_since) if focus_label: parts.append(focus_label) - if yolo_active: + if yolo_active and _ok("yolo"): parts.append("⚠ YOLO") + # Session token total (Σ) — opt-in only via an explicit fields + # list, so default bars never widen. + total_tokens = snapshot.get("session_total_tokens", 0) + if total_tokens and field_set is not None and "total_tokens" in field_set: + parts.append(f"Σ{format_token_count_compact(total_tokens)}") + if not parts: + parts = [f"⚕ {snapshot['model_short']}"] return self._right_align_status_title(" │ ".join(parts), session_title, width) except Exception: return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}" @@ -7277,23 +7340,39 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): battery_style = self._battery_status_style(snapshot.get("battery_category", "dim")) focus_label = snapshot.get("focus_label") or "" session_title = snapshot.get("session_title") or "" + field_set = self._get_status_bar_field_set() + + def _ok(name: str) -> bool: + return field_set is None or name in field_set + + if not _ok("goal"): + goal_segment = "" + if not _ok("focus"): + focus_label = "" + + def _append(frag_list, sep, *pieces): + if frag_list: + frag_list.append(("class:status-bar-dim", sep)) + frag_list.extend(pieces) if width < 52: - frags = [ - ("class:status-bar", " ⚕ "), - ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " · "), - ("class:status-bar-dim", duration_label), - ] + frags = [] + if _ok("model"): + frags.append(("class:status-bar", " ⚕ ")) + frags.append(("class:status-bar-strong", snapshot["model_short"])) + if _ok("duration"): + _append(frags, " · ", ("class:status-bar-dim", duration_label)) if goal_segment: - frags.append(("class:status-bar-dim", " · ")) - frags.append(("class:status-bar-strong", goal_segment)) + _append(frags, " · ", ("class:status-bar-strong", goal_segment)) if focus_label: - frags.append(("class:status-bar-dim", " · ")) - frags.append(("class:status-bar-strong", focus_label)) - if yolo_active: - frags.append(("class:status-bar-dim", " · ")) - frags.append(("class:status-bar-yolo", "⚠ YOLO")) + _append(frags, " · ", ("class:status-bar-strong", focus_label)) + if yolo_active and _ok("yolo"): + _append(frags, " · ", ("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"] @@ -7303,98 +7382,96 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): bg_count = snapshot.get("active_background_tasks", 0) bg_proc_count = snapshot.get("active_background_processes", 0) bg_subagent_count = snapshot.get("active_background_subagents", 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}")) - if bg_subagent_count: - frags.append(("class:status-bar-dim", " · ")) - frags.append(("class:status-bar-strong", f"⛓ {bg_subagent_count}")) + frags = [] + if _ok("model"): + frags.append(("class:status-bar", " ⚕ ")) + frags.append(("class:status-bar-strong", snapshot["model_short"])) + if _ok("context_pct"): + _append(frags, " · ", (self._status_bar_context_style(percent), percent_label)) + if compressions and _ok("compressions"): + _append(frags, " · ", (self._compression_count_style(compressions), f"🗜️ {compressions}")) + if bg_count and _ok("bg_tasks"): + _append(frags, " · ", ("class:status-bar-strong", f"▶ {bg_count}")) + if bg_proc_count and _ok("bg_processes"): + _append(frags, " · ", ("class:status-bar-strong", f"⚙ {bg_proc_count}")) + if bg_subagent_count and _ok("bg_subagents"): + _append(frags, " · ", ("class:status-bar-strong", f"⛓ {bg_subagent_count}")) if goal_segment: - frags.append(("class:status-bar-dim", " · ")) - frags.append(("class:status-bar-strong", goal_segment)) - frags.extend([ - ("class:status-bar-dim", " · "), - ("class:status-bar-dim", duration_label), - ]) + _append(frags, " · ", ("class:status-bar-strong", goal_segment)) + if _ok("duration"): + _append(frags, " · ", ("class:status-bar-dim", duration_label)) if focus_label: - frags.append(("class:status-bar-dim", " · ")) - frags.append(("class:status-bar-strong", focus_label)) - if yolo_active: - frags.append(("class:status-bar-dim", " · ")) - frags.append(("class:status-bar-yolo", "⚠ YOLO")) + _append(frags, " · ", ("class:status-bar-strong", focus_label)) + if yolo_active and _ok("yolo"): + _append(frags, " · ", ("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: - 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) bg_subagent_count = snapshot.get("active_background_subagents", 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}")) - if bg_subagent_count: - frags.append(("class:status-bar-dim", " │ ")) - frags.append(("class:status-bar-strong", f"⛓ {bg_subagent_count}")) + frags = [] + if _ok("model"): + frags.append(("class:status-bar", " ⚕ ")) + frags.append(("class:status-bar-strong", 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 --" + _append(frags, " │ ", ("class:status-bar-dim", context_label)) + if _ok("context_pct"): + _append( + frags, + " │ ", + (bar_style, self._build_context_bar(percent)), + ("class:status-bar-dim", " "), + (bar_style, percent_label), + ) + if compressions and _ok("compressions"): + _append(frags, " │ ", (self._compression_count_style(compressions), f"🗜️ {compressions}")) + if bg_count and _ok("bg_tasks"): + _append(frags, " │ ", ("class:status-bar-strong", f"▶ {bg_count}")) + if bg_proc_count and _ok("bg_processes"): + _append(frags, " │ ", ("class:status-bar-strong", f"⚙ {bg_proc_count}")) + if bg_subagent_count and _ok("bg_subagents"): + _append(frags, " │ ", ("class:status-bar-strong", f"⛓ {bg_subagent_count}")) if goal_segment: - frags.append(("class:status-bar-dim", " │ ")) - frags.append(("class:status-bar-strong", goal_segment)) - frags.extend([ - ("class:status-bar-dim", " │ "), - ("class:status-bar-dim", duration_label), - ]) + _append(frags, " │ ", ("class:status-bar-strong", goal_segment)) + if _ok("duration"): + _append(frags, " │ ", ("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 prompt_elapsed and _ok("prompt_elapsed"): + _append(frags, " │ ", ("class:status-bar-dim", prompt_elapsed)) # Position 8: idle time since the last final agent response idle_since = snapshot.get("idle_since") - if idle_since: - frags.append(("class:status-bar-dim", " │ ")) - frags.append(("class:status-bar-dim", idle_since)) + if idle_since and _ok("idle_since"): + _append(frags, " │ ", ("class:status-bar-dim", idle_since)) # Persistent focus-view badge — so the reduced-output mode # is never invisible (mirrors the YOLO badge convention). if focus_label: - frags.append(("class:status-bar-dim", " │ ")) - frags.append(("class:status-bar-strong", focus_label)) - if yolo_active: - frags.append(("class:status-bar-dim", " │ ")) - frags.append(("class:status-bar-yolo", "⚠ YOLO")) + _append(frags, " │ ", ("class:status-bar-strong", focus_label)) + if yolo_active and _ok("yolo"): + _append(frags, " │ ", ("class:status-bar-yolo", "⚠ YOLO")) + # Session token total (Σ) — opt-in only via an explicit + # fields list, so default bars never widen. + total_tokens = snapshot.get("session_total_tokens", 0) + if total_tokens and field_set is not None and "total_tokens" in field_set: + _append(frags, " │ ", ("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", " ")) # Stash indicator (📌 N) — appended after all width tiers so the diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 78bdda6682..7fafeccaad 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1602,6 +1602,19 @@ DEFAULT_CONFIG = { "enabled": False, "fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide }, + # CLI/TUI interactive status bar field customization (mirrors the + # runtime_footer.fields pattern above). When the list is non-empty, + # only the listed fields appear; the built-in order is preserved + # (the config controls visibility, not ordering). Empty = show the + # default set. Available: model, context_detail, context_pct, + # compressions, bg_tasks, bg_processes, bg_subagents, goal, + # duration, prompt_elapsed, idle_since, focus, yolo, total_tokens. + # total_tokens (session Σ) is opt-in only — it never shows unless + # listed here. Narrow terminals still drop wide-mode-only fields + # (context_detail, prompt_elapsed, idle_since) regardless of config. + "status_bar": { + "fields": [], # empty = built-in defaults (all fields) + }, "copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled" # Petdex animated mascot (https://github.com/crafter-station/petdex). # A purely cosmetic sprite that reacts to agent activity across the diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 41491d3979..ab77872e1d 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -370,3 +370,141 @@ class TestIdleSinceLastTurn: assert snapshot["idle_since"].startswith("✓ ") + + +class TestStatusBarFieldConfig: + """Tests for display.status_bar.fields config customization (#41909).""" + + 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.object(cli_mod, "CLI_CONFIG", {"display": {"status_bar": {"fields": fields}}}): + text = cli_obj._build_status_bar_text(width=width) + return text + + def test_default_fields_show_all(self): + """With no config, 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, + ) + with patch.object(cli_mod, "CLI_CONFIG", {}): + 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): + 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): + 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): + text = self._cli_with_fields(["context_pct"]) + assert "%" in text + assert "claude-sonnet-4-20250514" not in text + + def test_compressions_only(self): + 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): + 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): + 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.object(cli_mod, "CLI_CONFIG", {}): + 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 + assert "12.4K/200K" not in text + + def test_field_config_never_empties_the_bar(self): + """A fields list matching nothing still anchors on the model name.""" + text = self._cli_with_fields(["nonexistent_field"]) + assert "claude-sonnet-4-20250514" in text + + def test_fragments_respect_field_config(self): + 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.object(cli_mod, "CLI_CONFIG", {"display": {"status_bar": {"fields": ["model", "duration"]}}}), \ + patch.object(cli_obj, "_get_tui_terminal_width", return_value=120): + 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): + """Config controls visibility, not ordering — model stays first.""" + text = self._cli_with_fields(["duration", "model", "compressions"]) + model_pos = text.find("claude-sonnet-4-20250514") + comp_pos = text.find("🗜️") + dur_pos = text.find("15m") + assert 0 <= model_pos < comp_pos < dur_pos + + def test_empty_fields_list_uses_defaults(self): + text = self._cli_with_fields([]) + assert "claude-sonnet-4-20250514" in text + assert "12.4K/200K" in text + assert "🗜️" in text + + def test_field_set_is_cached_per_instance(self): + cli_obj = _make_cli() + with patch.object(cli_mod, "CLI_CONFIG", {"display": {"status_bar": {"fields": ["model"]}}}): + first = cli_obj._get_status_bar_field_set() + # Cache holds even if config object changes afterwards (per-session semantics). + with patch.object(cli_mod, "CLI_CONFIG", {"display": {"status_bar": {"fields": ["duration"]}}}): + second = cli_obj._get_status_bar_field_set() + assert first == second == frozenset({"model"}) diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 06fe5cc6a9..3263158ba5 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1868,6 +1868,8 @@ display: runtime_footer: # Gateway: append a runtime-context footer to final replies enabled: false fields: ["model", "context_pct", "cwd"] + status_bar: # CLI/TUI: choose which status-bar fields are visible + fields: [] # empty = show the default set; see below file_mutation_verifier: true # Append an advisory footer when write_file/patch calls failed this turn credits_notices: true # Nous credits status-bar notices (usage bands, grant-spent, depleted). false = silence them; /usage still works cli_rebuild_scrollback_on_redraw: false # Classic CLI: also wipe terminal scrollback (CSI 3J) on /redraw / Ctrl+L / width-change resize recovery. Enable when a terminal/tmux stack stamps stale prompt chrome into scrollback on maximize/restore. @@ -1968,6 +1970,26 @@ Tool progress requires a gateway adapter that can display progress updates safel Focus view is **display-only**. It never edits conversation history, the system prompt, tool schemas, or any request payload — hidden detail is suppressed on screen, never discarded, and prompt caching is completely unaffected. +### Status-bar field selection (CLI/TUI) + +The interactive status bar at the bottom of the CLI/TUI shows the model, context usage, compression count, background-activity counters, timers, and mode badges. `display.status_bar.fields` chooses which of those are visible — useful for a minimal bar (just model + duration) or for surfacing the opt-in session token total: + +```yaml +display: + status_bar: + fields: ["model", "duration", "total_tokens"] # visibility only; built-in order is preserved +``` + +Supported fields: `model`, `context_detail` (used/total tokens), `context_pct` (percent + meter), `compressions`, `bg_tasks`, `bg_processes`, `bg_subagents`, `goal`, `duration`, `prompt_elapsed`, `idle_since`, `focus`, `yolo`, 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`. +- The config controls **visibility, not order**; fields render in their built-in positions. +- Narrow terminals still drop wide-mode-only fields (`context_detail`, `prompt_elapsed`, `idle_since`) regardless of config. +- The battery indicator, session title, and prompt-stash indicator have their own toggles (`/battery`, `/title`) and are not governed by this list. +- Display-only: no effect on prompt caching or request payloads. Changes take effect on the next session start. + ### Runtime-metadata footer (gateway only) When `display.runtime_footer.enabled: true`, Hermes appends a small runtime-context footer to the **final** message of each gateway turn. The current footer can show the model, context-window percentage, and current working directory. Off by default; opt in per-gateway if your team wants every reply to include this provenance.