fix(cli): keep composer editable during compression

This commit is contained in:
Lucas Policastro 2026-07-21 00:09:03 +00:00 committed by Teknium
parent 2cabeeabca
commit 0bc7fb2b3b
3 changed files with 47 additions and 6 deletions

22
cli.py
View file

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

View file

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

View file

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