mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
feat(cli): /focus — reduced-output view with hidden-line recovery and status indicator
Display-only port of Claude Code /focus; composes with existing /verbose tool-progress modes.
This commit is contained in:
parent
e1ace0ac98
commit
d6fa2709de
16 changed files with 1199 additions and 1 deletions
86
cli.py
86
cli.py
|
|
@ -4113,6 +4113,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
# YAML 1.1 parses bare `off` as boolean False — normalise to string.
|
||||
_raw_tp = CLI_CONFIG["display"].get("tool_progress", "all")
|
||||
self.tool_progress_mode = "off" if _raw_tp is False else str(_raw_tp)
|
||||
# focus_view: display-only reduced-output mode (/focus). When on, the
|
||||
# tool-progress mode is snapped to "off" so the EXISTING suppression
|
||||
# path hides per-tool lines, and the pre-focus mode is stashed so
|
||||
# /focus off restores it. Purely cosmetic — never changes what is sent
|
||||
# to the model. See hermes_cli/focus_view.py.
|
||||
self._focus_view_enabled = bool(CLI_CONFIG["display"].get("focus_view", False))
|
||||
self._focus_saved_tool_progress = None
|
||||
self._focus_hidden_lines = 0
|
||||
self._focus_last_counted_tool = None
|
||||
if self._focus_view_enabled:
|
||||
from hermes_cli.focus_view import (
|
||||
FOCUS_TOOL_PROGRESS_MODE,
|
||||
normalize_tool_progress_mode,
|
||||
)
|
||||
|
||||
self._focus_saved_tool_progress = normalize_tool_progress_mode(
|
||||
self.tool_progress_mode
|
||||
)
|
||||
self.tool_progress_mode = FOCUS_TOOL_PROGRESS_MODE
|
||||
# resume_display: "full" (show history) | "minimal" (one-liner only)
|
||||
self.resume_display = CLI_CONFIG["display"].get("resume_display", "full")
|
||||
# bell_on_complete: play terminal bell (\a) when agent finishes a response
|
||||
|
|
@ -5091,8 +5110,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
"active_background_subagents": 0,
|
||||
"battery_label": "",
|
||||
"battery_category": "dim",
|
||||
# Focus view badge (/focus). Persistent indicator so the reduced
|
||||
# output mode is never invisible. Display-only.
|
||||
"focus_label": "",
|
||||
}
|
||||
|
||||
try:
|
||||
from hermes_cli.focus_view import focus_statusbar_segment
|
||||
|
||||
snapshot["focus_label"] = focus_statusbar_segment(
|
||||
bool(getattr(self, "_focus_view_enabled", False))
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Battery read-out (first status-bar element when enabled). Reads are
|
||||
# memoised for a few seconds inside agent.battery, so polling it on
|
||||
# every status-bar repaint is cheap.
|
||||
|
|
@ -5715,6 +5746,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
duration_label = snapshot["duration"]
|
||||
battery_label = snapshot.get("battery_label") or ""
|
||||
battery_prefix = f"{battery_label} │ " if battery_label else ""
|
||||
focus_label = snapshot.get("focus_label") or ""
|
||||
|
||||
yolo_active = self._is_session_yolo_active()
|
||||
goal_segment = self._status_bar_goal_segment(snapshot)
|
||||
|
|
@ -5722,6 +5754,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
text = f"{battery_prefix}⚕ {snapshot['model_short']} · {duration_label}"
|
||||
if goal_segment:
|
||||
text += f" · {goal_segment}"
|
||||
if focus_label:
|
||||
text += f" · {focus_label}"
|
||||
if yolo_active:
|
||||
text += " · ⚠ YOLO"
|
||||
return self._trim_status_bar_text(text, width)
|
||||
|
|
@ -5744,6 +5778,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
if goal_segment:
|
||||
parts.append(goal_segment)
|
||||
parts.append(duration_label)
|
||||
if focus_label:
|
||||
parts.append(focus_label)
|
||||
if yolo_active:
|
||||
parts.append("⚠ YOLO")
|
||||
return self._trim_status_bar_text(" · ".join(parts), width)
|
||||
|
|
@ -5779,6 +5815,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
idle_since = snapshot.get("idle_since")
|
||||
if idle_since:
|
||||
parts.append(idle_since)
|
||||
if focus_label:
|
||||
parts.append(focus_label)
|
||||
if yolo_active:
|
||||
parts.append("⚠ YOLO")
|
||||
return self._trim_status_bar_text(" │ ".join(parts), width)
|
||||
|
|
@ -5801,6 +5839,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
goal_segment = self._status_bar_goal_segment(snapshot)
|
||||
battery_label = snapshot.get("battery_label") or ""
|
||||
battery_style = self._battery_status_style(snapshot.get("battery_category", "dim"))
|
||||
focus_label = snapshot.get("focus_label") or ""
|
||||
|
||||
if width < 52:
|
||||
frags = [
|
||||
|
|
@ -5812,6 +5851,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
if goal_segment:
|
||||
frags.append(("class:status-bar-dim", " · "))
|
||||
frags.append(("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"))
|
||||
|
|
@ -5849,6 +5891,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
("class:status-bar-dim", " · "),
|
||||
("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"))
|
||||
|
|
@ -5905,6 +5950,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
if idle_since:
|
||||
frags.append(("class:status-bar-dim", " │ "))
|
||||
frags.append(("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"))
|
||||
|
|
@ -9644,6 +9694,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
self._handle_timestamps_command(cmd_original)
|
||||
elif canonical == "verbose":
|
||||
self._toggle_verbose()
|
||||
elif canonical == "focus":
|
||||
self._handle_focus_command(cmd_original)
|
||||
elif canonical == "footer":
|
||||
self._handle_footer_command(cmd_original)
|
||||
elif canonical == "yolo":
|
||||
|
|
@ -10302,6 +10354,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
idx = 2 # default to "all"
|
||||
self.tool_progress_mode = cycle[(idx + 1) % len(cycle)]
|
||||
|
||||
# /verbose is the explicit tool-progress control, so cycling it takes
|
||||
# ownership of the mode back from focus view. Leaving _focus_view_enabled
|
||||
# set would show a "focus" status-bar badge and hidden-line counts while
|
||||
# tool lines were visibly printing. Display-only state change.
|
||||
if getattr(self, "_focus_view_enabled", False):
|
||||
self._focus_view_enabled = False
|
||||
self._focus_saved_tool_progress = None
|
||||
self._focus_hidden_lines = 0
|
||||
self._focus_last_counted_tool = None
|
||||
try:
|
||||
from hermes_cli.focus_view import FOCUS_CONFIG_KEY
|
||||
|
||||
save_config_value(FOCUS_CONFIG_KEY, False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.agent:
|
||||
self.agent.reasoning_callback = self._current_reasoning_callback()
|
||||
# Keep the live agent's tool_progress_mode in sync so the
|
||||
|
|
@ -11465,6 +11533,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
self._turn_summary_record(
|
||||
function_name, kwargs.get("result"), kwargs.get("is_error", False)
|
||||
)
|
||||
# Focus view: count the scrollback line we are NOT printing, so the
|
||||
# post-turn recovery line can report how much was hidden. Counted
|
||||
# against the pre-focus tool-progress mode, so a user who already
|
||||
# had /verbose off is never told focus hid something it didn't.
|
||||
if getattr(self, "_focus_view_enabled", False):
|
||||
try:
|
||||
self._note_focus_hidden_line(function_name or "")
|
||||
except Exception:
|
||||
pass
|
||||
# Print stacked scrollback line for "new" / "all" / "verbose" modes.
|
||||
# "verbose" was previously omitted here, so non-streaming model
|
||||
# calls (MoA aggregator, copilot-acp) rendered each tool only into
|
||||
|
|
@ -13346,6 +13423,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
pass
|
||||
|
||||
|
||||
# Focus view: dim recovery line reporting what was hidden this turn
|
||||
# (and how to reveal it). Printed after the response so the turn
|
||||
# reads prompt → answer → "⋯ N tool lines hidden". Display-only;
|
||||
# resets the counter for the next turn.
|
||||
try:
|
||||
self._emit_focus_recovery_line()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Play terminal bell when agent finishes (if enabled).
|
||||
# Works over SSH — the bell propagates to the user's terminal.
|
||||
if self.bell_on_complete:
|
||||
|
|
|
|||
|
|
@ -2492,6 +2492,158 @@ class CLICommandsMixin:
|
|||
# right after process_command() returns (see cli.py main loop).
|
||||
self._pending_agent_seed = composed
|
||||
|
||||
def _handle_focus_command(self, cmd_original: str) -> None:
|
||||
"""Toggle or inspect focus view — the reduced-output display mode.
|
||||
|
||||
Usage:
|
||||
/focus → toggle
|
||||
/focus on|off → explicit
|
||||
/focus status → show current state
|
||||
|
||||
Focus view is a DISPLAY-ONLY mode. It composes with the existing
|
||||
``/verbose`` tool-progress machinery rather than adding a second
|
||||
suppression mechanism: turning it on snaps ``tool_progress_mode`` to
|
||||
``"off"`` (the same value ``/verbose off`` uses, honoured by
|
||||
``agent/tool_executor.py`` and ``_on_tool_progress``) after stashing
|
||||
whatever mode the user had, and turning it off restores that mode
|
||||
verbatim. On top of that it adds the two things ``/verbose off``
|
||||
lacks: a per-turn hidden-line count with a recovery hint, and a
|
||||
persistent ``focus`` segment in the status bar.
|
||||
|
||||
Nothing here touches conversation history, the system prompt, or any
|
||||
request payload — the model sees an identical turn either way.
|
||||
"""
|
||||
from cli import _cprint, save_config_value
|
||||
from hermes_cli.colors import Colors as _Colors
|
||||
from hermes_cli.focus_view import (
|
||||
FOCUS_CONFIG_KEY,
|
||||
FOCUS_TOOL_PROGRESS_MODE,
|
||||
format_focus_status,
|
||||
format_focus_toggle_message,
|
||||
normalize_tool_progress_mode,
|
||||
resolve_focus_arg,
|
||||
)
|
||||
|
||||
arg = ""
|
||||
try:
|
||||
parts = (cmd_original or "").strip().split(None, 1)
|
||||
if len(parts) > 1:
|
||||
arg = parts[1].strip()
|
||||
except Exception:
|
||||
arg = ""
|
||||
|
||||
current = bool(getattr(self, "_focus_view_enabled", False))
|
||||
action, target = resolve_focus_arg(arg, current)
|
||||
|
||||
if action == "usage":
|
||||
_cprint(" Usage: /focus [on|off|status]")
|
||||
return
|
||||
|
||||
# The mode /focus off will restore. While focus is ON the live
|
||||
# tool_progress_mode is "off", so the pre-focus mode is the stash.
|
||||
restore_mode = normalize_tool_progress_mode(
|
||||
getattr(self, "_focus_saved_tool_progress", None)
|
||||
if current
|
||||
else getattr(self, "tool_progress_mode", "all")
|
||||
)
|
||||
|
||||
if action == "status":
|
||||
body = format_focus_status(current, restore_mode)
|
||||
head, _, tail = body.partition("\n")
|
||||
label, _, rest = head.partition(":")
|
||||
state_color = _Colors.GREEN if current else _Colors.DIM
|
||||
_cprint(
|
||||
f" {_Colors.BOLD}{label}:{_Colors.RESET}"
|
||||
f"{state_color}{rest}{_Colors.RESET}"
|
||||
+ (f"\n{_Colors.DIM} {tail.strip()}{_Colors.RESET}" if tail else "")
|
||||
)
|
||||
return
|
||||
|
||||
if target == current:
|
||||
# Idempotent explicit set — report without rewriting config.
|
||||
_cprint(f" {format_focus_toggle_message(current, restore_mode)}")
|
||||
return
|
||||
|
||||
if target:
|
||||
# Stash the user's configured mode, then reuse the EXISTING
|
||||
# suppression path by snapping to "off".
|
||||
self._focus_saved_tool_progress = restore_mode
|
||||
self._set_tool_progress_mode(FOCUS_TOOL_PROGRESS_MODE)
|
||||
else:
|
||||
self._set_tool_progress_mode(restore_mode)
|
||||
self._focus_saved_tool_progress = None
|
||||
|
||||
self._focus_view_enabled = bool(target)
|
||||
self._focus_hidden_lines = 0
|
||||
save_config_value(FOCUS_CONFIG_KEY, bool(target))
|
||||
|
||||
state = (
|
||||
f"{_Colors.GREEN}enabled{_Colors.RESET}" if target
|
||||
else f"{_Colors.DIM}disabled{_Colors.RESET}"
|
||||
)
|
||||
message = format_focus_toggle_message(bool(target), restore_mode)
|
||||
# Re-colour just the enabled/disabled word so the line matches siblings.
|
||||
for word in ("enabled", "disabled"):
|
||||
if word in message:
|
||||
message = message.replace(word, state, 1)
|
||||
break
|
||||
_cprint(f" {message}")
|
||||
|
||||
def _set_tool_progress_mode(self, mode: str) -> None:
|
||||
"""Set the live tool-progress mode on both the CLI and the agent.
|
||||
|
||||
Extracted so ``/focus`` and ``/verbose`` share one write path — the
|
||||
agent copy is what ``agent/tool_executor.py`` gates on, and forgetting
|
||||
it means the new mode only takes effect after an agent rebuild.
|
||||
"""
|
||||
from hermes_cli.focus_view import normalize_tool_progress_mode
|
||||
|
||||
normalized = normalize_tool_progress_mode(mode)
|
||||
self.tool_progress_mode = normalized
|
||||
agent = getattr(self, "agent", None)
|
||||
if agent is not None:
|
||||
try:
|
||||
agent.tool_progress_mode = normalized
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _note_focus_hidden_line(self, function_name: str) -> None:
|
||||
"""Count one tool line that focus view is suppressing this turn.
|
||||
|
||||
Counted against the mode the user had BEFORE focus snapped things to
|
||||
"off", so a user who already ran ``/verbose off`` is never told that
|
||||
focus hid lines it did not hide.
|
||||
"""
|
||||
if not getattr(self, "_focus_view_enabled", False):
|
||||
return
|
||||
from hermes_cli.focus_view import would_display_tool_line
|
||||
|
||||
saved = getattr(self, "_focus_saved_tool_progress", None)
|
||||
last = getattr(self, "_focus_last_counted_tool", None)
|
||||
if not would_display_tool_line(saved, function_name, last):
|
||||
return
|
||||
self._focus_last_counted_tool = function_name
|
||||
self._focus_hidden_lines = int(getattr(self, "_focus_hidden_lines", 0)) + 1
|
||||
|
||||
def _emit_focus_recovery_line(self) -> None:
|
||||
"""Print the dim post-turn recovery line and reset the counter."""
|
||||
count = int(getattr(self, "_focus_hidden_lines", 0) or 0)
|
||||
self._focus_hidden_lines = 0
|
||||
self._focus_last_counted_tool = None
|
||||
if not getattr(self, "_focus_view_enabled", False):
|
||||
return
|
||||
from hermes_cli.focus_view import format_hidden_line
|
||||
|
||||
line = format_hidden_line(count)
|
||||
if not line:
|
||||
return
|
||||
try:
|
||||
from cli import _DIM, _RST, _cprint
|
||||
|
||||
_cprint(f" {_DIM}{line}{_RST}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _handle_footer_command(self, cmd_original: str) -> None:
|
||||
"""Toggle or inspect ``display.runtime_footer.enabled`` from the CLI.
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose -> log",
|
||||
"Configuration", cli_only=True,
|
||||
gateway_config_gate="display.tool_progress_command"),
|
||||
CommandDef("focus", "Toggle focus view — show only your prompt and the final response",
|
||||
"Configuration", cli_only=True, args_hint="[on|off|status]",
|
||||
subcommands=("on", "off", "status")),
|
||||
CommandDef("footer", "Toggle gateway runtime-metadata footer on final replies",
|
||||
"Configuration", args_hint="[on|off|status]",
|
||||
subcommands=("on", "off", "status")),
|
||||
|
|
|
|||
|
|
@ -1954,6 +1954,14 @@ DEFAULT_CONFIG = {
|
|||
# Show a color-coded battery read-out as the first status-bar element in
|
||||
# the CLI/TUI (off by default). No-op on machines without a battery.
|
||||
"battery": False,
|
||||
# Focus view (/focus): display-only reduced-output mode. When true the
|
||||
# CLI/TUI pins tool_progress to "off" (reusing the existing suppression
|
||||
# path), reports a per-turn hidden-line count with a recovery hint, and
|
||||
# pins a "focus" segment in the status bar. focus_saved_tool_progress
|
||||
# holds the mode /focus off restores. Never affects what is sent to the
|
||||
# model — see hermes_cli/focus_view.py.
|
||||
"focus_view": False,
|
||||
"focus_saved_tool_progress": "all",
|
||||
"skin": "default",
|
||||
# UI language for static user-facing messages (approval prompts, a
|
||||
# handful of gateway slash-command replies). Does NOT affect agent
|
||||
|
|
|
|||
166
hermes_cli/focus_view.py
Normal file
166
hermes_cli/focus_view.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Focus view — a display-only reduced-output mode.
|
||||
|
||||
``/focus`` answers one question the existing ``/verbose`` cycle cannot:
|
||||
*"just show me my prompt and the answer — and tell me what you hid."*
|
||||
|
||||
``/verbose off`` already silences per-tool progress lines (the
|
||||
``tool_progress_mode == "off"`` gate in ``agent/tool_executor.py`` and the
|
||||
scrollback gate in ``HermesCLI._on_tool_progress``). Focus view **composes
|
||||
with** that machinery instead of duplicating it:
|
||||
|
||||
* turning focus ON snaps ``tool_progress_mode`` to ``"off"`` and remembers the
|
||||
mode the user had configured, so the *existing* suppression path does the
|
||||
actual hiding;
|
||||
* turning focus OFF restores that remembered mode verbatim;
|
||||
* on top of that, focus view adds the two things ``/verbose off`` lacks —
|
||||
a per-turn count of what was hidden plus a recovery hint, and a persistent
|
||||
``focus`` segment in the status bar so the reduced mode is never invisible.
|
||||
|
||||
Everything in this module is **display-only**. Nothing here reads or mutates
|
||||
conversation history, the system prompt, tool schemas, or any request payload.
|
||||
Flipping focus view must never change a single byte of what is sent to the
|
||||
model — that invariant is covered by
|
||||
``tests/cli/test_focus_view.py::test_model_facing_messages_identical_with_focus_on_vs_off``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# Config key used by the sibling display toggles (/battery, /timestamps,
|
||||
# /footer) — a plain boolean under ``display``.
|
||||
FOCUS_CONFIG_KEY = "display.focus_view"
|
||||
|
||||
#: Tool-progress mode focus view snaps to. Deliberately the SAME value
|
||||
#: ``/verbose off`` uses so both features share one suppression path.
|
||||
FOCUS_TOOL_PROGRESS_MODE = "off"
|
||||
|
||||
#: Modes in which the CLI commits a per-tool scrollback line. Mirrors the gate
|
||||
#: in ``HermesCLI._on_tool_progress``; kept here so the hidden-line counter and
|
||||
#: the renderer can never drift apart.
|
||||
TOOL_PROGRESS_VISIBLE_MODES = frozenset({"new", "all", "verbose"})
|
||||
|
||||
#: Valid tool-progress modes (``log`` is a gateway-only extra step).
|
||||
TOOL_PROGRESS_MODES = ("off", "new", "all", "verbose")
|
||||
|
||||
#: Status-bar label. Short on purpose — the bar is width-constrained.
|
||||
FOCUS_STATUSBAR_LABEL = "◉ focus"
|
||||
|
||||
_ON_WORDS = frozenset({"on", "enable", "enabled", "true", "yes", "1"})
|
||||
_OFF_WORDS = frozenset({"off", "disable", "disabled", "false", "no", "0"})
|
||||
_STATUS_WORDS = frozenset({"status", "show", "?"})
|
||||
_TOGGLE_WORDS = frozenset({"", "toggle"})
|
||||
|
||||
FOCUS_USAGE = "Usage: /focus [on|off|status]"
|
||||
|
||||
|
||||
def normalize_tool_progress_mode(mode: object, default: str = "all") -> str:
|
||||
"""Coerce a raw config/attr value into a known tool-progress mode.
|
||||
|
||||
YAML 1.1 parses a bare ``off`` as ``False``, and older configs stored
|
||||
``True``/``False`` booleans, so this mirrors ``cli.py``'s normalisation.
|
||||
"""
|
||||
if mode is False:
|
||||
return "off"
|
||||
if mode is True:
|
||||
return "all"
|
||||
text = str(mode or "").strip().lower()
|
||||
if text in TOOL_PROGRESS_MODES:
|
||||
return text
|
||||
# ``log`` is a real gateway mode; treat any other unknown value as default.
|
||||
if text == "log":
|
||||
return "log"
|
||||
return default
|
||||
|
||||
|
||||
def resolve_focus_arg(arg: str, current: bool) -> tuple[str, Optional[bool]]:
|
||||
"""Map a ``/focus`` argument onto an action, following the sibling toggles.
|
||||
|
||||
Returns ``(action, target)`` where ``action`` is one of ``"set"``,
|
||||
``"status"`` or ``"usage"``. ``target`` is the requested enabled-state for
|
||||
``"set"`` and ``None`` otherwise. Bare ``/focus`` toggles, matching
|
||||
``/footer`` / ``/battery`` / ``/timestamps``.
|
||||
"""
|
||||
text = str(arg or "").strip().lower()
|
||||
if text in _STATUS_WORDS:
|
||||
return "status", None
|
||||
if text in _ON_WORDS:
|
||||
return "set", True
|
||||
if text in _OFF_WORDS:
|
||||
return "set", False
|
||||
if text in _TOGGLE_WORDS:
|
||||
return "set", not bool(current)
|
||||
return "usage", None
|
||||
|
||||
|
||||
def effective_tool_progress_mode(focus_enabled: bool, configured_mode: object) -> str:
|
||||
"""Return the tool-progress mode that should actually be in force.
|
||||
|
||||
Focus view wins while it is on (it *is* "tool progress off" plus reporting).
|
||||
When focus is off the user's configured mode is returned untouched — this is
|
||||
what makes ``/focus off`` restore ``/verbose verbose`` rather than clobbering
|
||||
it to ``all``.
|
||||
"""
|
||||
normalized = normalize_tool_progress_mode(configured_mode)
|
||||
if focus_enabled:
|
||||
return FOCUS_TOOL_PROGRESS_MODE
|
||||
return normalized
|
||||
|
||||
|
||||
def would_display_tool_line(
|
||||
mode: object,
|
||||
function_name: str,
|
||||
last_tool_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Would the CLI have committed a scrollback line for this tool call?
|
||||
|
||||
Used to count *honestly*: if the user already had ``/verbose off``, focus
|
||||
view is hiding nothing extra and must not claim otherwise. ``new`` mode
|
||||
skips consecutive repeats of the same tool, so the counter skips them too.
|
||||
"""
|
||||
if not function_name:
|
||||
return False
|
||||
normalized = normalize_tool_progress_mode(mode)
|
||||
if normalized not in TOOL_PROGRESS_VISIBLE_MODES:
|
||||
return False
|
||||
if normalized == "new" and function_name == last_tool_name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def format_hidden_line(count: int) -> Optional[str]:
|
||||
"""Dim post-turn recovery line, or ``None`` when nothing was hidden."""
|
||||
try:
|
||||
n = int(count)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if n <= 0:
|
||||
return None
|
||||
noun = "tool line" if n == 1 else "tool lines"
|
||||
return f"⋯ {n} {noun} hidden · /focus off to show"
|
||||
|
||||
|
||||
def focus_statusbar_segment(enabled: bool) -> str:
|
||||
"""Status-bar segment text for focus view (empty when off)."""
|
||||
return FOCUS_STATUSBAR_LABEL if enabled else ""
|
||||
|
||||
|
||||
def format_focus_status(enabled: bool, configured_mode: object) -> str:
|
||||
"""Human-readable ``/focus status`` body (no ANSI — callers colour it)."""
|
||||
state = "ON" if enabled else "OFF"
|
||||
if enabled:
|
||||
restore = normalize_tool_progress_mode(configured_mode)
|
||||
return (
|
||||
f"Focus view: {state} — only your prompt and the final response.\n"
|
||||
f" /focus off restores tool progress: {restore.upper()}"
|
||||
)
|
||||
mode = normalize_tool_progress_mode(configured_mode)
|
||||
return f"Focus view: {state} — tool progress: {mode.upper()}"
|
||||
|
||||
|
||||
def format_focus_toggle_message(enabled: bool, configured_mode: object) -> str:
|
||||
"""Confirmation line printed when focus view is switched (no ANSI)."""
|
||||
if enabled:
|
||||
return "Focus view enabled — just your prompt and the final response"
|
||||
mode = normalize_tool_progress_mode(configured_mode)
|
||||
return f"Focus view disabled — tool progress: {mode.upper()}"
|
||||
594
tests/cli/test_focus_view.py
Normal file
594
tests/cli/test_focus_view.py
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
"""Tests for ``/focus`` — the display-only reduced-output view.
|
||||
|
||||
Focus view composes with the existing ``/verbose`` tool-progress machinery
|
||||
rather than adding a second suppression mechanism. These tests cover:
|
||||
|
||||
* the on/off/status toggle state machine (``resolve_focus_arg``);
|
||||
* the hidden-line counter and its formatter (which must respect the
|
||||
pre-focus ``/verbose`` mode so it never over-claims);
|
||||
* status-bar segment composition in both CLI renderers;
|
||||
* the CLI command handler's stash/restore of ``tool_progress_mode``;
|
||||
* **the prompt-cache invariant** — a real fake turn is dispatched through
|
||||
``agent.tool_executor`` with focus on and with focus off, and the resulting
|
||||
model-facing ``messages`` lists must be byte-identical.
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.focus_view import (
|
||||
FOCUS_CONFIG_KEY,
|
||||
FOCUS_STATUSBAR_LABEL,
|
||||
FOCUS_TOOL_PROGRESS_MODE,
|
||||
effective_tool_progress_mode,
|
||||
focus_statusbar_segment,
|
||||
format_focus_status,
|
||||
format_focus_toggle_message,
|
||||
format_hidden_line,
|
||||
normalize_tool_progress_mode,
|
||||
resolve_focus_arg,
|
||||
would_display_tool_line,
|
||||
)
|
||||
from hermes_cli.cli_commands_mixin import CLICommandsMixin
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Toggle state machine — on | off | status | bare | garbage
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestToggleStateMachine:
|
||||
def test_bare_toggles_from_off_to_on(self):
|
||||
assert resolve_focus_arg("", False) == ("set", True)
|
||||
|
||||
def test_bare_toggles_from_on_to_off(self):
|
||||
assert resolve_focus_arg("", True) == ("set", False)
|
||||
|
||||
def test_explicit_toggle_word_behaves_like_bare(self):
|
||||
assert resolve_focus_arg("toggle", False) == ("set", True)
|
||||
assert resolve_focus_arg("toggle", True) == ("set", False)
|
||||
|
||||
@pytest.mark.parametrize("word", ["on", "ON", " on ", "enable", "true", "yes", "1"])
|
||||
def test_on_words(self, word):
|
||||
assert resolve_focus_arg(word, False) == ("set", True)
|
||||
|
||||
@pytest.mark.parametrize("word", ["off", "OFF", "disable", "false", "no", "0"])
|
||||
def test_off_words(self, word):
|
||||
assert resolve_focus_arg(word, True) == ("set", False)
|
||||
|
||||
@pytest.mark.parametrize("word", ["status", "show", "?", "STATUS"])
|
||||
def test_status_words_never_mutate(self, word):
|
||||
action, target = resolve_focus_arg(word, True)
|
||||
assert action == "status"
|
||||
assert target is None
|
||||
|
||||
@pytest.mark.parametrize("word", ["sideways", "onn", "--global", "2"])
|
||||
def test_garbage_reports_usage(self, word):
|
||||
assert resolve_focus_arg(word, False) == ("usage", None)
|
||||
|
||||
def test_explicit_set_is_idempotent(self):
|
||||
# /focus on while already on stays on (no accidental toggle).
|
||||
assert resolve_focus_arg("on", True) == ("set", True)
|
||||
assert resolve_focus_arg("off", False) == ("set", False)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Suppression respects the existing /verbose modes
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestComposesWithVerboseModes:
|
||||
def test_focus_on_snaps_to_the_existing_off_mode(self):
|
||||
# Focus view must reuse the tool_progress "off" path, not invent a mode.
|
||||
assert FOCUS_TOOL_PROGRESS_MODE == "off"
|
||||
for configured in ("off", "new", "all", "verbose"):
|
||||
assert effective_tool_progress_mode(True, configured) == "off"
|
||||
|
||||
@pytest.mark.parametrize("configured", ["off", "new", "all", "verbose"])
|
||||
def test_focus_off_leaves_the_configured_verbose_mode_untouched(self, configured):
|
||||
assert effective_tool_progress_mode(False, configured) == configured
|
||||
|
||||
def test_yaml_boolean_off_is_normalised(self):
|
||||
# YAML 1.1 parses a bare `off` as False.
|
||||
assert normalize_tool_progress_mode(False) == "off"
|
||||
assert normalize_tool_progress_mode(True) == "all"
|
||||
assert normalize_tool_progress_mode(None) == "all"
|
||||
assert normalize_tool_progress_mode("bogus") == "all"
|
||||
assert normalize_tool_progress_mode("log") == "log"
|
||||
|
||||
@pytest.mark.parametrize("mode", ["new", "all", "verbose"])
|
||||
def test_counts_lines_that_the_mode_would_have_shown(self, mode):
|
||||
assert would_display_tool_line(mode, "terminal") is True
|
||||
|
||||
def test_does_not_count_when_verbose_was_already_off(self):
|
||||
# A user who already ran /verbose off is hiding nothing extra — focus
|
||||
# view must not claim credit for suppressing lines nobody would see.
|
||||
assert would_display_tool_line("off", "terminal") is False
|
||||
assert would_display_tool_line(False, "terminal") is False
|
||||
|
||||
def test_new_mode_skips_consecutive_repeats_like_the_renderer(self):
|
||||
assert would_display_tool_line("new", "terminal", "terminal") is False
|
||||
assert would_display_tool_line("new", "read_file", "terminal") is True
|
||||
# "all" always counts, even repeats.
|
||||
assert would_display_tool_line("all", "terminal", "terminal") is True
|
||||
|
||||
def test_empty_tool_name_never_counts(self):
|
||||
assert would_display_tool_line("all", "") is False
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Hidden-count formatter + recovery line
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestHiddenCountFormatter:
|
||||
def test_zero_and_negative_produce_no_line(self):
|
||||
assert format_hidden_line(0) is None
|
||||
assert format_hidden_line(-3) is None
|
||||
|
||||
def test_singular_noun(self):
|
||||
assert format_hidden_line(1) == "⋯ 1 tool line hidden · /focus off to show"
|
||||
|
||||
def test_plural_noun(self):
|
||||
assert format_hidden_line(7) == "⋯ 7 tool lines hidden · /focus off to show"
|
||||
|
||||
def test_line_always_names_the_recovery_command(self):
|
||||
assert "/focus off" in format_hidden_line(2)
|
||||
|
||||
def test_non_numeric_is_tolerated(self):
|
||||
assert format_hidden_line(None) is None
|
||||
assert format_hidden_line("many") is None
|
||||
|
||||
|
||||
class _FocusHost(CLICommandsMixin):
|
||||
"""Minimal host exposing only the attributes the focus helpers read."""
|
||||
|
||||
def __init__(self, *, enabled=False, saved="all", tool_progress="all"):
|
||||
self._focus_view_enabled = enabled
|
||||
self._focus_saved_tool_progress = saved
|
||||
self._focus_hidden_lines = 0
|
||||
self._focus_last_counted_tool = None
|
||||
self.tool_progress_mode = tool_progress
|
||||
self.agent = None
|
||||
|
||||
|
||||
class TestHiddenCounterAccumulation:
|
||||
def test_counts_each_suppressed_tool_line(self):
|
||||
host = _FocusHost(enabled=True, saved="all")
|
||||
for name in ("terminal", "read_file", "web_search"):
|
||||
host._note_focus_hidden_line(name)
|
||||
assert host._focus_hidden_lines == 3
|
||||
|
||||
def test_counts_nothing_when_focus_is_off(self):
|
||||
host = _FocusHost(enabled=False, saved="all")
|
||||
host._note_focus_hidden_line("terminal")
|
||||
assert host._focus_hidden_lines == 0
|
||||
|
||||
def test_counts_nothing_when_verbose_was_already_off(self):
|
||||
host = _FocusHost(enabled=True, saved="off")
|
||||
for _ in range(5):
|
||||
host._note_focus_hidden_line("terminal")
|
||||
assert host._focus_hidden_lines == 0
|
||||
|
||||
def test_new_mode_dedupes_consecutive_repeats(self):
|
||||
host = _FocusHost(enabled=True, saved="new")
|
||||
host._note_focus_hidden_line("terminal")
|
||||
host._note_focus_hidden_line("terminal")
|
||||
host._note_focus_hidden_line("read_file")
|
||||
assert host._focus_hidden_lines == 2
|
||||
|
||||
def test_recovery_line_is_emitted_then_counter_resets(self):
|
||||
host = _FocusHost(enabled=True, saved="all")
|
||||
for name in ("terminal", "read_file"):
|
||||
host._note_focus_hidden_line(name)
|
||||
|
||||
with patch("cli._cprint") as printer:
|
||||
host._emit_focus_recovery_line()
|
||||
|
||||
assert printer.call_count == 1
|
||||
assert "2 tool lines hidden" in printer.call_args[0][0]
|
||||
assert "/focus off" in printer.call_args[0][0]
|
||||
# Reset so the next turn starts from zero.
|
||||
assert host._focus_hidden_lines == 0
|
||||
assert host._focus_last_counted_tool is None
|
||||
|
||||
def test_no_recovery_line_when_nothing_was_hidden(self):
|
||||
host = _FocusHost(enabled=True, saved="all")
|
||||
with patch("cli._cprint") as printer:
|
||||
host._emit_focus_recovery_line()
|
||||
printer.assert_not_called()
|
||||
|
||||
def test_no_recovery_line_when_focus_is_off(self):
|
||||
host = _FocusHost(enabled=False, saved="all")
|
||||
host._focus_hidden_lines = 4
|
||||
with patch("cli._cprint") as printer:
|
||||
host._emit_focus_recovery_line()
|
||||
printer.assert_not_called()
|
||||
assert host._focus_hidden_lines == 0
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# CLI command handler — stash / restore / persistence
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestFocusCommandHandler:
|
||||
def test_on_stashes_the_verbose_mode_and_snaps_to_off(self):
|
||||
host = _FocusHost(enabled=False, saved=None, tool_progress="verbose")
|
||||
with patch("cli.save_config_value", return_value=True) as saver, \
|
||||
patch("cli._cprint"):
|
||||
host._handle_focus_command("/focus on")
|
||||
|
||||
assert host._focus_view_enabled is True
|
||||
assert host.tool_progress_mode == "off"
|
||||
assert host._focus_saved_tool_progress == "verbose"
|
||||
saver.assert_called_once_with(FOCUS_CONFIG_KEY, True)
|
||||
|
||||
def test_off_restores_the_stashed_verbose_mode(self):
|
||||
host = _FocusHost(enabled=True, saved="new", tool_progress="off")
|
||||
with patch("cli.save_config_value", return_value=True) as saver, \
|
||||
patch("cli._cprint"):
|
||||
host._handle_focus_command("/focus off")
|
||||
|
||||
assert host._focus_view_enabled is False
|
||||
assert host.tool_progress_mode == "new"
|
||||
assert host._focus_saved_tool_progress is None
|
||||
saver.assert_called_once_with(FOCUS_CONFIG_KEY, False)
|
||||
|
||||
def test_round_trip_returns_to_the_original_mode(self):
|
||||
host = _FocusHost(enabled=False, saved=None, tool_progress="verbose")
|
||||
with patch("cli.save_config_value", return_value=True), patch("cli._cprint"):
|
||||
host._handle_focus_command("/focus")
|
||||
assert host.tool_progress_mode == "off"
|
||||
host._handle_focus_command("/focus")
|
||||
assert host.tool_progress_mode == "verbose"
|
||||
assert host._focus_view_enabled is False
|
||||
|
||||
def test_status_never_writes_config_or_changes_mode(self):
|
||||
host = _FocusHost(enabled=True, saved="all", tool_progress="off")
|
||||
with patch("cli.save_config_value") as saver, patch("cli._cprint") as printer:
|
||||
host._handle_focus_command("/focus status")
|
||||
saver.assert_not_called()
|
||||
assert host.tool_progress_mode == "off"
|
||||
assert host._focus_view_enabled is True
|
||||
assert "Focus view" in printer.call_args[0][0]
|
||||
|
||||
def test_garbage_argument_prints_usage_and_changes_nothing(self):
|
||||
host = _FocusHost(enabled=False, saved=None, tool_progress="all")
|
||||
with patch("cli.save_config_value") as saver, patch("cli._cprint") as printer:
|
||||
host._handle_focus_command("/focus sideways")
|
||||
saver.assert_not_called()
|
||||
assert host._focus_view_enabled is False
|
||||
assert host.tool_progress_mode == "all"
|
||||
assert "Usage: /focus" in printer.call_args[0][0]
|
||||
|
||||
def test_idempotent_on_does_not_reclobber_the_stash(self):
|
||||
host = _FocusHost(enabled=True, saved="verbose", tool_progress="off")
|
||||
with patch("cli.save_config_value") as saver, patch("cli._cprint"):
|
||||
host._handle_focus_command("/focus on")
|
||||
saver.assert_not_called()
|
||||
# The stash still points at the real pre-focus mode, not "off".
|
||||
assert host._focus_saved_tool_progress == "verbose"
|
||||
|
||||
def test_live_agent_mode_is_synced(self):
|
||||
host = _FocusHost(enabled=False, saved=None, tool_progress="all")
|
||||
host.agent = SimpleNamespace(tool_progress_mode="all")
|
||||
with patch("cli.save_config_value", return_value=True), patch("cli._cprint"):
|
||||
host._handle_focus_command("/focus on")
|
||||
# tool_executor gates on the AGENT copy — syncing it is what makes the
|
||||
# suppression take effect this turn instead of after an agent rebuild.
|
||||
assert host.agent.tool_progress_mode == "off"
|
||||
|
||||
def test_status_text_names_the_mode_focus_off_will_restore(self):
|
||||
body = format_focus_status(True, "verbose")
|
||||
assert "ON" in body
|
||||
assert "VERBOSE" in body
|
||||
off_body = format_focus_status(False, "new")
|
||||
assert "OFF" in off_body
|
||||
assert "NEW" in off_body
|
||||
|
||||
def test_toggle_messages_mirror_claude_code_wording(self):
|
||||
assert "enabled" in format_focus_toggle_message(True, "all")
|
||||
assert "disabled" in format_focus_toggle_message(False, "all")
|
||||
assert "ALL" in format_focus_toggle_message(False, "all")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Status-bar segment composition
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestStatusBarSegment:
|
||||
def test_segment_present_only_when_enabled(self):
|
||||
assert focus_statusbar_segment(True) == FOCUS_STATUSBAR_LABEL
|
||||
assert focus_statusbar_segment(False) == ""
|
||||
|
||||
def test_snapshot_exposes_focus_label(self):
|
||||
from cli import HermesCLI
|
||||
|
||||
host = HermesCLI.__new__(HermesCLI)
|
||||
host.model = "anthropic/claude-opus-4.6"
|
||||
from datetime import datetime
|
||||
|
||||
host.session_start = datetime.now()
|
||||
host.conversation_history = []
|
||||
host.agent = None
|
||||
host._focus_view_enabled = True
|
||||
|
||||
snapshot = HermesCLI._get_status_bar_snapshot(host)
|
||||
assert snapshot["focus_label"] == FOCUS_STATUSBAR_LABEL
|
||||
|
||||
host._focus_view_enabled = False
|
||||
assert HermesCLI._get_status_bar_snapshot(host)["focus_label"] == ""
|
||||
|
||||
@pytest.mark.parametrize("width", [40, 60, 120])
|
||||
def test_text_renderer_includes_the_badge_at_every_width_tier(self, width):
|
||||
from cli import HermesCLI
|
||||
|
||||
host = HermesCLI.__new__(HermesCLI)
|
||||
host.model = "opus"
|
||||
host._focus_view_enabled = True
|
||||
|
||||
snapshot = {
|
||||
"model_name": "opus",
|
||||
"model_short": "opus",
|
||||
"duration": "1m",
|
||||
"context_percent": 12,
|
||||
"context_tokens": 1000,
|
||||
"context_length": 200000,
|
||||
"compressions": 0,
|
||||
"active_background_tasks": 0,
|
||||
"active_background_processes": 0,
|
||||
"active_background_subagents": 0,
|
||||
"battery_label": "",
|
||||
"battery_category": "dim",
|
||||
"focus_label": FOCUS_STATUSBAR_LABEL,
|
||||
"prompt_elapsed": "",
|
||||
"idle_since": "",
|
||||
}
|
||||
|
||||
with patch.object(HermesCLI, "_get_status_bar_snapshot", return_value=snapshot), \
|
||||
patch.object(HermesCLI, "_is_session_yolo_active", return_value=False):
|
||||
text = HermesCLI._build_status_bar_text(host, width=width)
|
||||
|
||||
assert "focus" in text
|
||||
|
||||
@pytest.mark.parametrize("width", [40, 60, 120])
|
||||
def test_fragment_renderer_includes_the_badge_at_every_width_tier(self, width):
|
||||
from cli import HermesCLI
|
||||
|
||||
host = HermesCLI.__new__(HermesCLI)
|
||||
host.model = "opus"
|
||||
host._status_bar_visible = True
|
||||
host._model_picker_state = None
|
||||
host._focus_view_enabled = True
|
||||
|
||||
snapshot = {
|
||||
"model_name": "opus",
|
||||
"model_short": "opus",
|
||||
"duration": "1m",
|
||||
"context_percent": 12,
|
||||
"context_tokens": 1000,
|
||||
"context_length": 200000,
|
||||
"compressions": 0,
|
||||
"active_background_tasks": 0,
|
||||
"active_background_processes": 0,
|
||||
"active_background_subagents": 0,
|
||||
"battery_label": "",
|
||||
"battery_category": "dim",
|
||||
"focus_label": FOCUS_STATUSBAR_LABEL,
|
||||
"prompt_elapsed": "",
|
||||
"idle_since": "",
|
||||
}
|
||||
|
||||
with patch.object(HermesCLI, "_get_status_bar_snapshot", return_value=snapshot), \
|
||||
patch.object(HermesCLI, "_get_tui_terminal_width", return_value=width), \
|
||||
patch.object(HermesCLI, "_is_session_yolo_active", return_value=False):
|
||||
frags = HermesCLI._get_status_bar_fragments(host)
|
||||
|
||||
rendered = "".join(text for _, text in frags)
|
||||
assert "focus" in rendered
|
||||
|
||||
def test_badge_absent_from_fragments_when_focus_is_off(self):
|
||||
from cli import HermesCLI
|
||||
|
||||
host = HermesCLI.__new__(HermesCLI)
|
||||
host.model = "opus"
|
||||
host._status_bar_visible = True
|
||||
host._model_picker_state = None
|
||||
host._focus_view_enabled = False
|
||||
|
||||
snapshot = {
|
||||
"model_name": "opus",
|
||||
"model_short": "opus",
|
||||
"duration": "1m",
|
||||
"context_percent": 12,
|
||||
"context_tokens": 1000,
|
||||
"context_length": 200000,
|
||||
"compressions": 0,
|
||||
"active_background_tasks": 0,
|
||||
"active_background_processes": 0,
|
||||
"active_background_subagents": 0,
|
||||
"battery_label": "",
|
||||
"battery_category": "dim",
|
||||
"focus_label": "",
|
||||
"prompt_elapsed": "",
|
||||
"idle_since": "",
|
||||
}
|
||||
|
||||
with patch.object(HermesCLI, "_get_status_bar_snapshot", return_value=snapshot), \
|
||||
patch.object(HermesCLI, "_get_tui_terminal_width", return_value=120), \
|
||||
patch.object(HermesCLI, "_is_session_yolo_active", return_value=False):
|
||||
frags = HermesCLI._get_status_bar_fragments(host)
|
||||
|
||||
assert "focus" not in "".join(text for _, text in frags)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# PROMPT-CACHE INVARIANT: model-facing messages are identical either way
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def _make_agent(tool_progress_mode: str):
|
||||
"""Build a real AIAgent whose display mode is the only difference."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
tool_defs = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "search",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
]
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=tool_defs),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("hermes_cli.config.load_config", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
):
|
||||
agent = AIAgent(
|
||||
api_key="test-key-1234567890",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
# quiet_mode False so the display gate is genuinely exercised —
|
||||
# with quiet_mode True the tool_progress gate would be moot.
|
||||
quiet_mode=False,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
tool_progress_mode=tool_progress_mode,
|
||||
)
|
||||
agent.client = MagicMock()
|
||||
agent.tool_delay = 0
|
||||
agent._flush_messages_to_session_db = MagicMock()
|
||||
return agent
|
||||
|
||||
|
||||
def _tool_call(call_id: str, query: str):
|
||||
return SimpleNamespace(
|
||||
id=call_id,
|
||||
type="function",
|
||||
function=SimpleNamespace(
|
||||
name="web_search", arguments=json.dumps({"query": query})
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _run_fake_turn(tool_progress_mode: str, dispatch_mode: str = "sequential"):
|
||||
"""Dispatch an identical fake turn and return the model-facing messages."""
|
||||
agent = _make_agent(tool_progress_mode)
|
||||
assistant_message = SimpleNamespace(
|
||||
content="",
|
||||
tool_calls=[
|
||||
_tool_call("call-1", "alpha"),
|
||||
_tool_call("call-2", "beta"),
|
||||
_tool_call("call-3", "gamma"),
|
||||
],
|
||||
)
|
||||
messages: list = [
|
||||
{"role": "system", "content": "you are hermes"},
|
||||
{"role": "user", "content": "find three things"},
|
||||
]
|
||||
|
||||
def fake_dispatch(name, args, task_id, *positional, **kwargs):
|
||||
return json.dumps({"ok": args["query"]})
|
||||
|
||||
with (
|
||||
patch("run_agent.handle_function_call", side_effect=fake_dispatch),
|
||||
patch.object(agent, "_invoke_tool", side_effect=fake_dispatch),
|
||||
patch(
|
||||
"agent.tool_executor.maybe_persist_tool_result",
|
||||
side_effect=lambda **kwargs: kwargs["content"],
|
||||
),
|
||||
# Swallow display writes so the test doesn't spam stdout; the point is
|
||||
# what lands in `messages`, not what prints.
|
||||
patch("builtins.print"),
|
||||
):
|
||||
execute = getattr(agent, f"_execute_tool_calls_{dispatch_mode}")
|
||||
execute(assistant_message, messages, "task-focus")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
class TestModelFacingMessagesUnchanged:
|
||||
"""Focus view is display-only: the request payload must not shift a byte."""
|
||||
|
||||
@pytest.mark.parametrize("dispatch_mode", ["sequential", "concurrent"])
|
||||
def test_model_facing_messages_identical_with_focus_on_vs_off(self, dispatch_mode):
|
||||
# Focus ON == the existing tool_progress "off" suppression path.
|
||||
focus_on = _run_fake_turn(FOCUS_TOOL_PROGRESS_MODE, dispatch_mode)
|
||||
# Focus OFF == the default noisy display mode.
|
||||
focus_off = _run_fake_turn("all", dispatch_mode)
|
||||
|
||||
assert focus_on == focus_off, (
|
||||
"focus view altered the model-facing messages — display-only "
|
||||
"invariant violated (prompt cache would break)"
|
||||
)
|
||||
# Sanity: the turn really did produce tool results to compare.
|
||||
assert [m["role"] for m in focus_on].count("tool") == 3
|
||||
assert json.loads(focus_on[-1]["content"]) == {"ok": "gamma"}
|
||||
|
||||
def test_every_verbose_mode_produces_the_same_messages(self):
|
||||
# /focus composes with /verbose, so no tool-progress mode may change
|
||||
# the payload — otherwise the composition itself would be unsafe.
|
||||
baseline = _run_fake_turn("all")
|
||||
for mode in ("off", "new", "verbose"):
|
||||
assert _run_fake_turn(mode) == baseline, f"mode {mode} altered messages"
|
||||
|
||||
def test_toggling_focus_does_not_touch_conversation_history(self):
|
||||
host = _FocusHost(enabled=False, saved=None, tool_progress="all")
|
||||
history = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
host.conversation_history = history
|
||||
snapshot = json.dumps(history)
|
||||
|
||||
with patch("cli.save_config_value", return_value=True), patch("cli._cprint"):
|
||||
host._handle_focus_command("/focus on")
|
||||
host._note_focus_hidden_line("terminal")
|
||||
host._emit_focus_recovery_line()
|
||||
host._handle_focus_command("/focus off")
|
||||
|
||||
assert json.dumps(host.conversation_history) == snapshot
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Registry wiring
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestCommandRegistration:
|
||||
def test_focus_is_registered_with_the_sibling_toggle_convention(self):
|
||||
from hermes_cli.commands import resolve_command
|
||||
|
||||
cmd = resolve_command("focus")
|
||||
assert cmd is not None
|
||||
assert cmd.category == "Configuration"
|
||||
assert cmd.args_hint == "[on|off|status]"
|
||||
assert set(cmd.subcommands) == {"on", "off", "status"}
|
||||
|
||||
def test_verbose_cycle_releases_focus_view(self):
|
||||
# /verbose is the explicit tool-progress control; cycling it must clear
|
||||
# the focus badge so the indicator can never contradict the display.
|
||||
from cli import HermesCLI
|
||||
|
||||
host = HermesCLI.__new__(HermesCLI)
|
||||
host.tool_progress_mode = "off"
|
||||
host._focus_view_enabled = True
|
||||
host._focus_saved_tool_progress = "all"
|
||||
host._focus_hidden_lines = 3
|
||||
host._focus_last_counted_tool = "terminal"
|
||||
host.agent = None
|
||||
|
||||
with patch("cli.save_config_value", return_value=True), patch("cli._cprint"):
|
||||
HermesCLI._toggle_verbose(host)
|
||||
|
||||
assert host._focus_view_enabled is False
|
||||
assert host._focus_saved_tool_progress is None
|
||||
assert host._focus_hidden_lines == 0
|
||||
assert host.tool_progress_mode == "new"
|
||||
|
|
@ -13365,6 +13365,70 @@ def _(rid, params: dict) -> dict:
|
|||
agent.verbose_logging = nv == "verbose"
|
||||
return _ok(rid, {"key": key, "value": nv})
|
||||
|
||||
if key == "focus":
|
||||
# Focus view — display-only reduced-output mode (/focus). Composes with
|
||||
# the tool_progress machinery rather than duplicating it: enabling it
|
||||
# pins tool_progress to "off" (the same value /verbose off uses) after
|
||||
# stashing the configured mode, and disabling it restores that mode.
|
||||
# Nothing about the request payload changes.
|
||||
from hermes_cli.focus_view import (
|
||||
FOCUS_TOOL_PROGRESS_MODE,
|
||||
normalize_tool_progress_mode,
|
||||
resolve_focus_arg,
|
||||
)
|
||||
|
||||
cfg_f = _load_cfg()
|
||||
_display_f = cfg_f.get("display")
|
||||
d_f: dict = _display_f if isinstance(_display_f, dict) else {}
|
||||
cur_focus = bool(d_f.get("focus_view", False))
|
||||
action, target = resolve_focus_arg(str(value or ""), cur_focus)
|
||||
if action == "usage":
|
||||
return _err(rid, 4002, f"unknown focus value: {value} (use on|off|status)")
|
||||
if action == "status" or target is None:
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"key": key,
|
||||
"value": "on" if cur_focus else "off",
|
||||
"tool_progress": _load_tool_progress_mode(),
|
||||
},
|
||||
)
|
||||
|
||||
if target:
|
||||
saved = normalize_tool_progress_mode(
|
||||
(d_f.get("focus_saved_tool_progress") or _load_tool_progress_mode())
|
||||
if cur_focus
|
||||
else _load_tool_progress_mode()
|
||||
)
|
||||
_write_config_key("display.focus_saved_tool_progress", saved)
|
||||
_write_config_key("display.tool_progress", FOCUS_TOOL_PROGRESS_MODE)
|
||||
effective = FOCUS_TOOL_PROGRESS_MODE
|
||||
else:
|
||||
saved = normalize_tool_progress_mode(
|
||||
d_f.get("focus_saved_tool_progress") or "all"
|
||||
)
|
||||
_write_config_key("display.tool_progress", saved)
|
||||
effective = saved
|
||||
_write_config_key("display.focus_view", bool(target))
|
||||
|
||||
if session:
|
||||
session["focus_view"] = bool(target)
|
||||
session["tool_progress_mode"] = effective
|
||||
agent_f = session.get("agent")
|
||||
if agent_f is not None:
|
||||
try:
|
||||
agent_f.tool_progress_mode = effective
|
||||
except Exception:
|
||||
pass
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"key": key,
|
||||
"value": "on" if target else "off",
|
||||
"tool_progress": effective,
|
||||
},
|
||||
)
|
||||
|
||||
if key in {"approval_mode", "approvals.mode"}:
|
||||
raw = str(value or "").strip().lower()
|
||||
if raw not in _APPROVAL_MODES:
|
||||
|
|
@ -14552,6 +14616,13 @@ def _(rid, params: dict) -> dict:
|
|||
display.get("tui_statusbar", "top") if isinstance(display, dict) else "top"
|
||||
)
|
||||
return _ok(rid, {"value": _coerce_statusbar(raw)})
|
||||
if key == "focus":
|
||||
display = _load_cfg().get("display")
|
||||
on = bool(display.get("focus_view", False)) if isinstance(display, dict) else False
|
||||
return _ok(
|
||||
rid,
|
||||
{"value": "on" if on else "off", "tool_progress": _load_tool_progress_mode()},
|
||||
)
|
||||
if key == "mouse":
|
||||
display = _load_cfg().get("display")
|
||||
return _ok(rid, {"value": _display_mouse_tracking(display)})
|
||||
|
|
@ -15433,6 +15504,49 @@ def _(rid, params: dict) -> dict:
|
|||
except Exception as exc:
|
||||
return _err(rid, 5030, f"moa unavailable: {exc}")
|
||||
|
||||
if name == "focus":
|
||||
# /focus is display-only. Route it through the same config.set branch the
|
||||
# Ink TUI slash command uses so both surfaces share one state machine and
|
||||
# one persistence path. Returns a plain notice line for the transcript.
|
||||
from hermes_cli.focus_view import (
|
||||
format_focus_status,
|
||||
format_focus_toggle_message,
|
||||
resolve_focus_arg,
|
||||
)
|
||||
|
||||
_display_focus = _load_cfg().get("display")
|
||||
_d_focus: dict = _display_focus if isinstance(_display_focus, dict) else {}
|
||||
_cur_focus = bool(_d_focus.get("focus_view", False))
|
||||
_action, _target = resolve_focus_arg(arg, _cur_focus)
|
||||
if _action == "usage":
|
||||
return _err(rid, 4004, "usage: /focus [on|off|status]")
|
||||
if _action == "status":
|
||||
_saved = _d_focus.get("focus_saved_tool_progress") or _load_tool_progress_mode()
|
||||
return _ok(
|
||||
rid,
|
||||
{"type": "exec", "output": format_focus_status(_cur_focus, _saved)},
|
||||
)
|
||||
_res = _methods["config.set"](
|
||||
rid,
|
||||
{
|
||||
"key": "focus",
|
||||
"value": "on" if _target else "off",
|
||||
"session_id": params.get("session_id", ""),
|
||||
},
|
||||
)
|
||||
if "error" in _res:
|
||||
return _res
|
||||
_payload = _res.get("result") or {}
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"type": "exec",
|
||||
"output": format_focus_toggle_message(
|
||||
bool(_target), _payload.get("tool_progress") or "all"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if name == "retry":
|
||||
if not session:
|
||||
return _err(rid, 4001, "no active session to retry")
|
||||
|
|
|
|||
|
|
@ -324,6 +324,9 @@ export interface UiState {
|
|||
compact: boolean
|
||||
detailsMode: DetailsMode
|
||||
detailsModeCommandOverride: boolean
|
||||
// Focus view (/focus) — display-only reduced-output mode. Drives the
|
||||
// persistent `◉ focus` status-bar badge; never affects request payloads.
|
||||
focusView: boolean
|
||||
info: null | SessionInfo
|
||||
liveSessionCount: number
|
||||
inlineDiffs: boolean
|
||||
|
|
|
|||
|
|
@ -554,6 +554,44 @@ export const coreCommands: SlashCommand[] = [
|
|||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'toggle focus view — show only your prompt and the final response [on|off|status]',
|
||||
name: 'focus',
|
||||
run: (arg, ctx) => {
|
||||
const mode = arg.trim().toLowerCase()
|
||||
const current = ctx.ui.focusView
|
||||
|
||||
// `/focus status` reports without writing, matching the CLI surface.
|
||||
if (mode === 'status' || mode === 'show' || mode === '?') {
|
||||
return ctx.transcript.sys(
|
||||
current
|
||||
? 'focus view on — only your prompt and the final response'
|
||||
: 'focus view off'
|
||||
)
|
||||
}
|
||||
|
||||
const next = flagFromArg(mode, current)
|
||||
|
||||
if (next === null) {
|
||||
return ctx.transcript.sys('usage: /focus [on|off|status]')
|
||||
}
|
||||
|
||||
// Display-only: Python owns the tool_progress stash/restore so /focus off
|
||||
// returns to whatever /verbose mode the user had. Optimistically patch the
|
||||
// badge so the status bar flips on the same frame.
|
||||
patchUiState({ focusView: next })
|
||||
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'focus', value: next ? 'on' : 'off' }).catch(() => {})
|
||||
|
||||
queueMicrotask(() =>
|
||||
ctx.transcript.sys(
|
||||
next
|
||||
? 'focus view enabled — just your prompt and the final response'
|
||||
: 'focus view disabled'
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
aliases: ['sb'],
|
||||
help: 'status bar position (on|off|top|bottom)',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const buildUiState = (): UiState => ({
|
|||
compact: false,
|
||||
detailsMode: 'collapsed',
|
||||
detailsModeCommandOverride: false,
|
||||
focusView: false,
|
||||
indicatorStyle: DEFAULT_INDICATOR_STYLE,
|
||||
info: null,
|
||||
liveSessionCount: 0,
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ export const applyDisplay = (
|
|||
compact: !!d.tui_compact,
|
||||
detailsMode: resolveDetailsMode(d),
|
||||
detailsModeCommandOverride: false,
|
||||
focusView: !!d.focus_view,
|
||||
indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator),
|
||||
inlineDiffs: d.inline_diffs !== false,
|
||||
mouseTracking: normalizeMouseTracking(d),
|
||||
|
|
|
|||
|
|
@ -434,6 +434,7 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
|||
|
||||
export function StatusRule({
|
||||
battery,
|
||||
focusView,
|
||||
cwdLabel,
|
||||
cols,
|
||||
busy,
|
||||
|
|
@ -570,6 +571,11 @@ export function StatusRule({
|
|||
// so it consumes tail budget LAST and drops first on a narrow terminal.
|
||||
const showDevCredits = !!devCreditsText && fits(SEP + stringWidth(devCreditsText))
|
||||
|
||||
// Focus-view badge. Pinned (not tail-budgeted) on purpose: the whole point of
|
||||
// the indicator is that the user can never be in reduced-output mode without
|
||||
// seeing it, so it must not drop off a narrow terminal.
|
||||
const showFocus = !!focusView
|
||||
|
||||
const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => {
|
||||
event.stopImmediatePropagation?.()
|
||||
onSessionCountClick?.()
|
||||
|
|
@ -634,6 +640,12 @@ export function StatusRule({
|
|||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
{showFocus ? (
|
||||
<Box flexDirection="row" flexShrink={0}>
|
||||
<Text color={t.color.muted}>{' │ '}</Text>
|
||||
<Text color={t.color.warn}>◉ focus</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{showBar ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
|
|
@ -811,6 +823,8 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
|
|||
|
||||
interface StatusRuleProps {
|
||||
battery?: BatteryInfo | null
|
||||
// Focus view (/focus) badge — display-only reduced-output indicator.
|
||||
focusView?: boolean
|
||||
bgCount: number
|
||||
lastTurnEndedAt?: null | number
|
||||
liveSessionCount: number
|
||||
|
|
|
|||
|
|
@ -492,6 +492,7 @@ const StatusRulePane = memo(function StatusRulePane({
|
|||
busy={ui.busy}
|
||||
cols={composer.cols}
|
||||
cwdLabel={status.cwdLabel}
|
||||
focusView={ui.focusView}
|
||||
indicatorStyle={ui.indicatorStyle}
|
||||
lastTurnEndedAt={status.lastTurnEndedAt}
|
||||
liveSessionCount={ui.liveSessionCount}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ export interface ConfigDisplayConfig {
|
|||
bell_on_complete?: boolean
|
||||
busy_input_mode?: string
|
||||
details_mode?: string
|
||||
/** Focus view (/focus) — display-only reduced-output mode. */
|
||||
focus_view?: boolean
|
||||
inline_diffs?: boolean
|
||||
mouse_tracking?: boolean | null | number | string
|
||||
sections?: Record<string, string>
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
|||
| `/codex-runtime [auto\|codex_app_server\|on\|off]` | Toggle the optional [Codex app-server runtime](../user-guide/features/codex-app-server-runtime) for OpenAI/Codex models. `auto` (default) uses Hermes' standard chat completions; `codex_app_server` hands turns to a `codex app-server` subprocess for native shell, apply_patch, ChatGPT subscription auth, and migrated Codex plugins. Effective on next session. |
|
||||
| `/personality` | Set a predefined personality |
|
||||
| `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. |
|
||||
| `/focus [on\|off\|status]` | Toggle **focus view** — a display-only reduced-output mode showing just your prompt and the final response. Composes with `/verbose`: turning it on snaps tool progress to `off` and remembers your previous mode, and `/focus off` restores it. Each turn ends with a dim recovery line (`⋯ 7 tool lines hidden · /focus off to show`) and a persistent `◉ focus` badge sits in the status bar so you always know you're in the reduced view. Nothing is sent differently to the model — detail is hidden, never discarded. |
|
||||
| `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. Options: `normal`, `fast`, `status`. |
|
||||
| `/reasoning` | Manage reasoning effort and display (usage: /reasoning [level\|show\|hide]) |
|
||||
| `/skin` | Show or change the display skin/theme |
|
||||
|
|
@ -257,9 +258,10 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
|||
|
||||
## Notes
|
||||
|
||||
- `/skin`, `/snapshot`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/battery`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands.
|
||||
- `/skin`, `/snapshot`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/battery`, `/focus`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands.
|
||||
- `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces.
|
||||
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
|
||||
- `/focus` and `/verbose` share one suppression path (`display.tool_progress`), so they can never contradict each other: `/focus on` pins tool progress to `off` and stashes your mode under `display.focus_saved_tool_progress`; `/focus off` restores it; cycling `/verbose` while focus is on takes the mode back and clears the focus badge. Focus view is display-only — it never changes conversation history, the system prompt, or anything sent to the model, so it has zero prompt-cache impact.
|
||||
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands.
|
||||
- `/status`, `/egress`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
|
||||
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.
|
||||
|
|
|
|||
|
|
@ -1510,6 +1510,7 @@ This controls both the `text_to_speech` tool and spoken replies in voice mode (`
|
|||
display:
|
||||
tool_progress: all # off | new | all | verbose
|
||||
tool_progress_command: false # Enable /verbose slash command in messaging gateway
|
||||
focus_view: false # CLI focus view (/focus) — reduced output, display-only
|
||||
platforms: {} # Per-platform display overrides (see below)
|
||||
tool_progress_overrides: {} # DEPRECATED — use display.platforms instead
|
||||
interim_assistant_messages: true # Gateway: send natural mid-turn assistant updates as separate messages
|
||||
|
|
@ -1616,6 +1617,18 @@ In the CLI, cycle through these modes with `/verbose`. To use `/verbose` in mess
|
|||
|
||||
Tool progress requires a gateway adapter that can display progress updates safely. Platforms without message editing support, including Signal, suppress tool-progress bubbles even if `/verbose` saves a non-`off` mode.
|
||||
|
||||
### Focus view (`/focus`, CLI + TUI)
|
||||
|
||||
`display.focus_view: true` enables **focus view** — a reduced-output display mode for when you want the answer, not the play-by-play. It is a thin layer over the same `tool_progress` machinery rather than a second suppression path:
|
||||
|
||||
- turning it on pins `tool_progress` to `off` and stashes your previous mode in `display.focus_saved_tool_progress`;
|
||||
- `/focus off` restores that mode exactly, so a `/verbose verbose` setup survives a round trip;
|
||||
- each completed turn ends with a dim recovery line — `⋯ 7 tool lines hidden · /focus off to show` — counted against your *pre-focus* mode, so it never claims to have hidden lines you had already turned off;
|
||||
- a persistent `◉ focus` badge sits in the status bar (both the prompt_toolkit CLI and the Ink TUI) so the reduced mode is never invisible;
|
||||
- cycling `/verbose` while focus is on hands the mode back to `/verbose` and clears the badge.
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue