feat(cli): add display.status_bar.fields config for customizing status bar
Allow users to control which fields appear in the interactive CLI status bar via display.status_bar.fields in config.yaml. Available fields: model, context_pct, context_detail, compressions, bg_tasks, bg_processes, duration, prompt_elapsed, yolo, total_tokens. When the list is empty (default), all fields are shown as before. The field order is fixed (model always first); the config controls visibility only. Narrow terminals (<76 cols) automatically drop context_detail regardless of config. total_tokens is opt-in only (not shown by default) to avoid width overflow in the prompt_toolkit fragment renderer. Closes #41909
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user