From 0bc7fb2b3bdcfdcc93989ee29680b6daadcb93b0 Mon Sep 17 00:00:00 2001 From: Lucas Policastro Date: Tue, 21 Jul 2026 00:09:03 +0000 Subject: [PATCH] fix(cli): keep composer editable during compression --- cli.py | 22 +++++++++++++++++----- tests/cli/test_manual_compress.py | 29 +++++++++++++++++++++++++++++ tests/test_cli_manual_compress.py | 2 +- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/cli.py b/cli.py index a46cec97fed6..bcb6b49fb99f 100644 --- a/cli.py +++ b/cli.py @@ -4169,6 +4169,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._pending_tool_info: dict = {} # function_name -> list of (preview, args) for stacked scrollback self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup) self._command_running = False + self._command_blocks_input = False self._command_status = "" # Petdex mascot (opt-in via display.pet). The base CLI mirrors the TUI's # PetPane: a half-block sprite above the prompt that reacts to agent @@ -6182,9 +6183,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): return _COMMAND_SPINNER_FRAMES[frame_idx] @contextmanager - def _busy_command(self, status: str): - """Expose a temporary busy state in the TUI while a slash command runs.""" + def _busy_command(self, status: str, *, blocks_input: bool = True): + """Expose a temporary busy state in the TUI while a slash command runs. + + Most synchronous slash commands must reserve the composer because their + completion changes the active session state. Manual compression is safe + to draft through: the queued input is processed against the compacted + history after the command completes. + """ + previous_blocks_input = getattr(self, "_command_blocks_input", False) self._command_running = True + self._command_blocks_input = blocks_input self._command_status = status self._invalidate(min_interval=0.0) try: @@ -6192,6 +6201,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): yield finally: self._command_running = False + self._command_blocks_input = previous_blocks_input self._command_status = "" self._invalidate(min_interval=0.0) @@ -10010,7 +10020,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): return original_count = len(self.conversation_history) - with self._busy_command("Compressing context..."): + with self._busy_command("Compressing context...", blocks_input=False): try: from agent.model_metadata import estimate_request_tokens_rough from agent.manual_compression_feedback import summarize_manual_compression @@ -13436,6 +13446,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Slash command loading state self._command_running = False + self._command_blocks_input = False self._command_status = "" # Secure secret capture state for skill setup @@ -14397,7 +14408,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): style='class:input-area', multiline=True, wrap_lines=True, - read_only=Condition(lambda: bool(cli_ref._command_running)), + read_only=Condition(lambda: bool(cli_ref._command_blocks_input)), history=FileHistory(str(self._history_file)), # complete_while_typing fires the completer on every keystroke. The # completer does blocking work — fuzzy @-file indexing shells out to @@ -14608,8 +14619,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if cli_ref._command_running: frame = cli_ref._command_spinner_frame() + detail = "input temporarily disabled" if cli_ref._command_blocks_input else "input stays active; Enter queues" return [ - ('class:hint', f' {frame} command in progress · input temporarily disabled'), + ('class:hint', f' {frame} command in progress · {detail}'), ] return [] diff --git a/tests/cli/test_manual_compress.py b/tests/cli/test_manual_compress.py index 79ff8edb6b20..976afd29a16e 100644 --- a/tests/cli/test_manual_compress.py +++ b/tests/cli/test_manual_compress.py @@ -14,6 +14,35 @@ def _make_history() -> list[dict[str, str]]: ] +def test_manual_compress_keeps_tui_composer_editable(capsys): + """A follow-up can be drafted and queued while /compress runs.""" + shell = _make_cli() + history = _make_history() + shell.conversation_history = history + shell.agent = MagicMock() + shell.agent.compression_enabled = True + shell.agent._cached_system_prompt = "" + shell.agent.tools = None + shell.agent.session_id = shell.session_id + + observed = {} + + def compress(*_args, **_kwargs): + # The classic TUI's TextArea consults this state for its read_only + # condition. Compression must retain its status spinner without + # preventing the user from drafting the next prompt. + observed["running"] = shell._command_running + observed["blocks_input"] = getattr(shell, "_command_blocks_input", shell._command_running) + return list(history), "" + + shell.agent._compress_context.side_effect = compress + + with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100): + shell._manual_compress() + + assert observed == {"running": True, "blocks_input": False} + + def test_manual_compress_reports_noop_without_success_banner(capsys): shell = _make_cli() history = _make_history() diff --git a/tests/test_cli_manual_compress.py b/tests/test_cli_manual_compress.py index bcd34a2333fd..bb8632e2f193 100644 --- a/tests/test_cli_manual_compress.py +++ b/tests/test_cli_manual_compress.py @@ -72,7 +72,7 @@ def test_manual_compress_does_not_pass_cached_system_prompt(monkeypatch): cli.agent = DummyAgent() cli.session_id = "old-session" cli._pending_title = "old title" - cli._busy_command = lambda _message: nullcontext() + cli._busy_command = lambda _message, **_kwargs: nullcontext() monkeypatch.setattr( "agent.manual_compression_feedback.summarize_manual_compression",