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:
teknium1 2026-07-26 14:36:09 -07:00 committed by Teknium
parent e1ace0ac98
commit d6fa2709de
16 changed files with 1199 additions and 1 deletions

86
cli.py
View file

@ -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:

View file

@ -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.

View file

@ -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")),

View file

@ -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
View 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()}"

View 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"

View file

@ -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")

View file

@ -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

View file

@ -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)',

View file

@ -16,6 +16,7 @@ const buildUiState = (): UiState => ({
compact: false,
detailsMode: 'collapsed',
detailsModeCommandOverride: false,
focusView: false,
indicatorStyle: DEFAULT_INDICATOR_STYLE,
info: null,
liveSessionCount: 0,

View file

@ -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),

View file

@ -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

View file

@ -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}

View file

@ -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>

View file

@ -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.

View file

@ -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.