diff --git a/cli.py b/cli.py index 07c77654371a..c48696a1e14d 100644 --- a/cli.py +++ b/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: diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index d515acf4a110..4ab6224fb657 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -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. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 956880173617..2ba32d598b8e 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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")), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7ecb2e0a4db0..176f13012ec4 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/hermes_cli/focus_view.py b/hermes_cli/focus_view.py new file mode 100644 index 000000000000..0ec6c076886c --- /dev/null +++ b/hermes_cli/focus_view.py @@ -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()}" diff --git a/tests/cli/test_focus_view.py b/tests/cli/test_focus_view.py new file mode 100644 index 000000000000..e6791d4f5d65 --- /dev/null +++ b/tests/cli/test_focus_view.py @@ -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" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d32cbb4fac18..95461910856a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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") diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 485c0ec28e24..d82fa1a90edf 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -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 diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 8fdbedce9f48..52f235f4222f 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -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('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)', diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index f311e8f2ce56..fe7a6674da3b 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -16,6 +16,7 @@ const buildUiState = (): UiState => ({ compact: false, detailsMode: 'collapsed', detailsModeCommandOverride: false, + focusView: false, indicatorStyle: DEFAULT_INDICATOR_STYLE, info: null, liveSessionCount: 0, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 6a1e5032111c..e8dd8b1334c6 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -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), diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 97994e827225..775825576516 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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({ ) : null} + {showFocus ? ( + + {' │ '} + ◉ focus + + ) : null} {showBar ? ( {' │ '} @@ -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 diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 1b4ba084fba0..660fcb881d16 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 719ffe9938d2..63219a1ab71f 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -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 diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index e196adefaabe..608ec8733f7a 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -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. diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 977fa6be2605..f7b015bba17d 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -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.