feat(cli): persist resolved approval/clarify prompts in scrollback (#44702)
Modal prompt panels (dangerous-command approval, clarify questions) live in the prompt_toolkit layout and vanish on the next repaint, leaving no trace of the question or the decision in chat history. Emit a dim one-line summary after each prompt resolves: ⚠ Approval: <command> → allowed for session ? Clarify: <question> → <answer> Gated on display.persist_prompts (default true). Detail and outcome are whitespace-collapsed and capped at 120 chars.
This commit is contained in:
@@ -456,6 +456,9 @@ def load_cli_config() -> Dict[str, Any]:
|
|||||||
"busy_input_mode": "interrupt",
|
"busy_input_mode": "interrupt",
|
||||||
"persistent_output": True,
|
"persistent_output": True,
|
||||||
"persistent_output_max_lines": 200,
|
"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",
|
"skin": "default",
|
||||||
},
|
},
|
||||||
@@ -9361,6 +9364,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
for line in reqs["details"].split("\n"):
|
for line in reqs["details"].split("\n"):
|
||||||
_cprint(f" {line}")
|
_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):
|
def _clarify_callback(self, question, choices):
|
||||||
"""
|
"""
|
||||||
Platform callback for the clarify tool. Called from the agent thread.
|
Platform callback for the clarify tool. Called from the agent thread.
|
||||||
@@ -9400,6 +9422,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
try:
|
try:
|
||||||
result = response_queue.get(timeout=1)
|
result = response_queue.get(timeout=1)
|
||||||
self._clarify_deadline = 0
|
self._clarify_deadline = 0
|
||||||
|
self._persist_prompt_summary("?", "Clarify", question, str(result))
|
||||||
return result
|
return result
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
remaining = self._clarify_deadline - _time.monotonic()
|
remaining = self._clarify_deadline - _time.monotonic()
|
||||||
@@ -9513,6 +9536,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
self._approval_state = None
|
self._approval_state = None
|
||||||
self._approval_deadline = 0
|
self._approval_deadline = 0
|
||||||
self._paint_now()
|
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
|
return result
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
remaining = self._approval_deadline - _time.monotonic()
|
remaining = self._approval_deadline - _time.monotonic()
|
||||||
|
|||||||
@@ -1429,6 +1429,10 @@ DEFAULT_CONFIG = {
|
|||||||
# behaves badly with replayed scrollback.
|
# behaves badly with replayed scrollback.
|
||||||
"persistent_output": True,
|
"persistent_output": True,
|
||||||
"persistent_output_max_lines": 200,
|
"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)
|
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||||||
# File-mutation verifier footer. When true (default), the agent
|
# File-mutation verifier footer. When true (default), the agent
|
||||||
# appends a one-line advisory to its final response whenever a
|
# appends a one-line advisory to its final response whenever a
|
||||||
|
|||||||
@@ -565,3 +565,80 @@ class TestApprovalCallbackThreadLocalWiring:
|
|||||||
# would hold a stale reference to a disposed CLI instance.
|
# would hold a stale reference to a disposed CLI instance.
|
||||||
assert seen["approval_after"] is None
|
assert seen["approval_after"] is None
|
||||||
assert seen["sudo_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
|
||||||
|
|||||||
Reference in New Issue
Block a user