From 8842d4804d70df3855a7587437548a173bb3564e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:16:06 -0700 Subject: [PATCH] fix(cli): keep monitor controls pinned and resize chrome isolated --- hermes_cli/cli_subagent_monitor.py | 17 +++++++++++------ hermes_cli/cli_terminal_mixin.py | 4 ++++ tests/cli/test_subagent_monitor_resize.py | 18 ++++++++++++++++++ tests/cli/test_subagent_monitor_ui.py | 15 +++++++++++++-- 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 tests/cli/test_subagent_monitor_resize.py diff --git a/hermes_cli/cli_subagent_monitor.py b/hermes_cli/cli_subagent_monitor.py index 86ea14dc3c..6e666e33c7 100644 --- a/hermes_cli/cli_subagent_monitor.py +++ b/hermes_cli/cli_subagent_monitor.py @@ -71,9 +71,9 @@ class SubagentMonitor: index = next((i for i, r in enumerate(self.entries) if r['subagent_id'] == self.selected_id), 0) self.selected_id = self.entries[(index + delta) % len(self.entries)]['subagent_id'] - def control(self, action, message=None): + def control(self, action, message=None, *, target=None): from tools.delegate_tool_registry import _handle_control_action - return json.loads(_handle_control_action(action, self.selected_id, message, getattr(self.cli, 'agent', None))) + return json.loads(_handle_control_action(action, target or self.selected_id, message, getattr(self.cli, 'agent', None))) def dock_text(self, *, columns, rows): if not self.entries: @@ -152,10 +152,13 @@ def build_monitor_application(monitor, **kwargs): return [('bold', _clip(title, app.output.get_size().columns))] def footer(): + narrow = app.output.get_size().columns < 60 if state['confirm']: - return 'Stop selected subagent? y confirm · Esc cancel' + return 'Stop? y yes · Esc cancel' if narrow else 'Stop selected subagent? y confirm · Esc cancel' if state['steering']: - return 'Enter queues guidance · Esc cancels (does not interrupt)' + return 'Enter send · Esc cancel' if narrow else 'Enter queues guidance · Esc cancels (does not interrupt)' + if narrow: + return 'PgUp/Dn · s steer x stop · Esc' if state['detail'] else '↑↓ select Enter tail F6 close' return ('Esc roster · PgUp/PgDn tail · s steer · x stop' if state['detail'] else '↑/↓ select · Enter tail · s steer · x stop · q/F6 close') @@ -182,13 +185,14 @@ def build_monitor_application(monitor, **kwargs): def start_steer(event): if monitor.selected: state['steering'] = True + state['target'] = monitor.selected_id app.layout.focus(steer) @kb.add('enter', filter=Condition(lambda: state['steering'])) def send_steer(event): if not steer.text.strip(): return - result = monitor.control('steer', steer.text) + result = monitor.control('steer', steer.text, target=state['target']) state['notice'] = result.get('error') or result.get('note') or str(result) steer.text = '' state['steering'] = False @@ -198,10 +202,11 @@ def build_monitor_application(monitor, **kwargs): def stop(event): if monitor.selected: state['confirm'] = True + state['target'] = monitor.selected_id @kb.add('y', filter=Condition(lambda: state['confirm'])) def confirm(event): - result = monitor.control('stop') + result = monitor.control('stop', target=state['target']) state['notice'] = result.get('error') or result.get('note') or str(result) state['confirm'] = False diff --git a/hermes_cli/cli_terminal_mixin.py b/hermes_cli/cli_terminal_mixin.py index 539415a519..133d5adc0b 100644 --- a/hermes_cli/cli_terminal_mixin.py +++ b/hermes_cli/cli_terminal_mixin.py @@ -216,6 +216,10 @@ class CLITerminalMixin: handled by ``_hermes_call_output_screen_diff`` (#83874). Suppression is cleared by a debounced timer so the bar returns during idle; next-submit stays a fast path. """ + # A debounced composer resize can arrive after the alternate screen opens. + # Its erase/replay belongs to the suspended composer, not the monitor. + if getattr(getattr(self, '_subagent_monitor', None), 'opening', False): + return from cli import _replay_output_history self._status_bar_suppressed_after_resize = True try: diff --git a/tests/cli/test_subagent_monitor_resize.py b/tests/cli/test_subagent_monitor_resize.py new file mode 100644 index 0000000000..883489fb36 --- /dev/null +++ b/tests/cli/test_subagent_monitor_resize.py @@ -0,0 +1,18 @@ +"""A queued composer resize must not erase the alternate-screen monitor.""" +from types import SimpleNamespace + + +def test_debounced_resize_does_not_write_over_subagent_monitor(): + from hermes_cli.cli_terminal_mixin import CLITerminalMixin + writes = [] + cli = SimpleNamespace( + _subagent_monitor=SimpleNamespace(opening=True), + _get_tui_terminal_width=lambda: 32, + _last_resize_width=90, + _clear_prompt_toolkit_screen=lambda *a, **kw: writes.append('erase'), + _redraw_rebuilds_scrollback=lambda: False, + _pet_queue_kitty_frame=lambda: None, + _schedule_status_bar_unsuppress=lambda app: writes.append('unsuppress'), + ) + CLITerminalMixin._recover_after_resize(cli, SimpleNamespace(), lambda: writes.append('resize')) + assert writes == [] diff --git a/tests/cli/test_subagent_monitor_ui.py b/tests/cli/test_subagent_monitor_ui.py index 6c47deb8c7..33f0be6f8b 100644 --- a/tests/cli/test_subagent_monitor_ui.py +++ b/tests/cli/test_subagent_monitor_ui.py @@ -23,17 +23,28 @@ def test_monitor_keys_navigate_steer_and_preserve_chat_draft(monkeypatch): async def run(): with create_pipe_input() as pipe: - app = build_monitor_application(dock, input=pipe, output=DummyOutput()) + output = DummyOutput() + from prompt_toolkit.data_structures import Size + output.get_size = lambda: Size(rows=14, columns=32) + app = build_monitor_application(dock, input=pipe, output=output) + footer = app.layout.container.children[-1].content.text() + assert 'F6' in footer and len(footer) <= 32 task = asyncio.create_task(app.run_async()) await asyncio.sleep(0.1) pipe.send_text('\x1b[B\rsFocus on tests\r') await asyncio.sleep(0.2) + pipe.send_text('x') + await asyncio.sleep(0.1) + registry._active_subagents.pop('second') + dock.refresh() + pipe.send_text('y') + await asyncio.sleep(0.1) pipe.send_text('\x1b') await asyncio.sleep(0.6) pipe.send_text('q') await asyncio.wait_for(task, 3) asyncio.run(run()) - assert dock.selected_id == 'second' + assert dock.selected_id == 'first' assert received == ['Focus on tests'] assert composer.text == 'my unfinished draft'