diff --git a/cli.py b/cli.py index 3a522f2f2a3..0212ed9e2f3 100644 --- a/cli.py +++ b/cli.py @@ -456,6 +456,9 @@ def load_cli_config() -> Dict[str, Any]: "busy_input_mode": "interrupt", "persistent_output": True, "persistent_output_max_lines": 200, + # Print a one-line summary of resolved modal prompts (approval / + # clarify) into scrollback so the decision survives the repaint. + "persist_prompts": True, "skin": "default", }, @@ -9361,6 +9364,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): for line in reqs["details"].split("\n"): _cprint(f" {line}") + def _persist_prompt_summary(self, icon: str, label: str, detail: str, outcome: str) -> None: + """Print a one-line scrollback summary of a resolved modal prompt. + + Modal panels (approval / clarify) live in the prompt_toolkit layout and + vanish on the next repaint, so the question and the decision leave no + trace in the terminal scrollback. When display.persist_prompts is on + (default), emit a dim single line after the prompt resolves so the + decision survives in chat history. + """ + if not CLI_CONFIG.get("display", {}).get("persist_prompts", True): + return + detail = " ".join(detail.split()) + if len(detail) > 120: + detail = detail[:119] + "…" + outcome = " ".join(outcome.split()) + if len(outcome) > 120: + outcome = outcome[:119] + "…" + _cprint(f"\n{_DIM}{icon} {label}: {detail} → {outcome}{_RST}") + def _clarify_callback(self, question, choices): """ Platform callback for the clarify tool. Called from the agent thread. @@ -9400,6 +9422,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): try: result = response_queue.get(timeout=1) self._clarify_deadline = 0 + self._persist_prompt_summary("?", "Clarify", question, str(result)) return result except queue.Empty: remaining = self._clarify_deadline - _time.monotonic() @@ -9513,6 +9536,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._approval_state = None self._approval_deadline = 0 self._paint_now() + _outcome_labels = { + "once": "allowed once", + "session": "allowed for session", + "always": "added to allowlist", + "deny": "denied", + } + self._persist_prompt_summary( + "⚠", "Approval", command, + _outcome_labels.get(result, str(result)), + ) return result except queue.Empty: remaining = self._approval_deadline - _time.monotonic() diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 226fdce639b..416ac415eb5 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1429,6 +1429,10 @@ DEFAULT_CONFIG = { # behaves badly with replayed scrollback. "persistent_output": True, "persistent_output_max_lines": 200, + # Print a one-line summary of resolved modal prompts (approval / + # clarify) into scrollback so the question and decision survive the + # panel repaint. Set false to keep scrollback untouched. + "persist_prompts": True, "inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage) # File-mutation verifier footer. When true (default), the agent # appends a one-line advisory to its final response whenever a diff --git a/tests/cli/test_cli_approval_ui.py b/tests/cli/test_cli_approval_ui.py index 334811addaa..0d58bf8f56a 100644 --- a/tests/cli/test_cli_approval_ui.py +++ b/tests/cli/test_cli_approval_ui.py @@ -565,3 +565,80 @@ class TestApprovalCallbackThreadLocalWiring: # would hold a stale reference to a disposed CLI instance. assert seen["approval_after"] is None assert seen["sudo_after"] is None + + +class TestPersistPromptSummary: + """display.persist_prompts — one-line scrollback record of resolved modals.""" + + def _resolve_approval(self, cli, answer, command="rm -rf /tmp/scratch"): + result = {} + + def _run(): + result["value"] = cli._approval_callback(command, "danger") + + t = threading.Thread(target=_run, daemon=True) + t.start() + deadline = time.time() + 2 + while cli._approval_state is None and time.time() < deadline: + time.sleep(0.01) + cli._approval_state["response_queue"].put(answer) + t.join(timeout=2) + return result["value"] + + def test_approval_resolution_prints_summary_line(self): + cli = _make_cli_stub() + printed = [] + with patch.object(cli_module, "_cprint", printed.append): + verdict = self._resolve_approval(cli, "session") + assert verdict == "session" + summary = "\n".join(printed) + assert "Approval" in summary + assert "rm -rf /tmp/scratch" in summary + assert "allowed for session" in summary + + def test_approval_summary_truncates_long_command(self): + cli = _make_cli_stub() + printed = [] + long_cmd = "sudo " + ("x" * 300) + with patch.object(cli_module, "_cprint", printed.append): + self._resolve_approval(cli, "deny", command=long_cmd) + summary = "\n".join(printed) + assert "denied" in summary + assert "…" in summary + # The raw 300-char tail must not be dumped wholesale. + assert "x" * 200 not in summary + + def test_persist_prompts_false_suppresses_summary(self): + cli = _make_cli_stub() + printed = [] + with patch.dict(cli_module.CLI_CONFIG.get("display", {}), {"persist_prompts": False}), \ + patch.object(cli_module, "_cprint", printed.append): + verdict = self._resolve_approval(cli, "once") + assert verdict == "once" + assert not any("Approval" in p for p in printed) + + def test_clarify_resolution_prints_summary_line(self): + cli = _make_cli_stub() + cli._clarify_state = None + cli._clarify_freetext = False + cli._clarify_deadline = 0 + printed = [] + result = {} + + def _run(): + result["value"] = cli._clarify_callback("Pick a path?", ["A", "B"]) + + with patch.object(cli_module, "_cprint", printed.append): + t = threading.Thread(target=_run, daemon=True) + t.start() + deadline = time.time() + 2 + while cli._clarify_state is None and time.time() < deadline: + time.sleep(0.01) + cli._clarify_state["response_queue"].put("B") + t.join(timeout=2) + + assert result["value"] == "B" + summary = "\n".join(printed) + assert "Clarify" in summary + assert "Pick a path?" in summary + assert "B" in summary