diff --git a/code_puppy/command_line/config_apply.py b/code_puppy/command_line/config_apply.py index 3febddb3b..8dc94ec16 100644 --- a/code_puppy/command_line/config_apply.py +++ b/code_puppy/command_line/config_apply.py @@ -71,6 +71,11 @@ def invalidate_post_write_caches(key: str) -> None: reset_session_model() clear_model_cache() + elif key == "subagent_panel_max_rows": + from code_puppy.config import get_subagent_panel_max_rows + from code_puppy.messaging.bottom_bar import get_bottom_bar + + get_bottom_bar().set_panel_max_rows(get_subagent_panel_max_rows()) def apply_setting( @@ -108,7 +113,21 @@ def apply_setting( requires_restart = False normalized_value = value - if key == "cancel_agent_key": + if key == "subagent_panel_max_rows": + try: + panel_max_rows = int(value.strip()) + except (AttributeError, ValueError): + return ApplyResult( + ok=False, + error="subagent_panel_max_rows must be 0 or a positive integer.", + ) + if panel_max_rows < 0: + return ApplyResult( + ok=False, + error="subagent_panel_max_rows must be 0 or a positive integer.", + ) + normalized_value = str(panel_max_rows) + elif key == "cancel_agent_key": from code_puppy.keymap import VALID_CANCEL_KEYS normalized_value = value.strip().lower() diff --git a/code_puppy/command_line/set_menu_catalog.py b/code_puppy/command_line/set_menu_catalog.py index 3f37ac462..8818f25d0 100644 --- a/code_puppy/command_line/set_menu_catalog.py +++ b/code_puppy/command_line/set_menu_catalog.py @@ -57,6 +57,7 @@ get_safety_permission_level, get_smooth_response_stream, get_smooth_thinking_stream, + get_subagent_panel_max_rows, get_subagent_recursion_limit, get_subagent_verbose, get_summarization_model_name, @@ -170,6 +171,16 @@ type_hint="int", effective_getter=get_subagent_recursion_limit, ), + Setting( + key="subagent_panel_max_rows", + display_name="Sub-agent Panel Max Rows", + description=( + "Maximum live sub-agent panel rows. Set to 0 (default) to show " + "every row that fits; terminal height always takes precedence." + ), + type_hint="int", + effective_getter=get_subagent_panel_max_rows, + ), Setting( key="subagent_verbose", display_name="Sub-agent Verbose", diff --git a/code_puppy/config.py b/code_puppy/config.py index 27d46a55a..bb76cc9fc 100644 --- a/code_puppy/config.py +++ b/code_puppy/config.py @@ -95,6 +95,24 @@ def get_subagent_recursion_limit() -> int: return limit if limit >= 0 else DEFAULT_SUBAGENT_RECURSION_LIMIT +def get_subagent_panel_max_rows() -> int: + """Return the optional sub-agent panel row ceiling. + + ``0`` means automatic: show every panel row that fits in the terminal. + Invalid or negative values safely fall back to that automatic behavior. + """ + cfg_val = get_value("subagent_panel_max_rows") + if cfg_val is None: + return 0 + + try: + max_rows = int(str(cfg_val).strip()) + except (TypeError, ValueError): + return 0 + + return max_rows if max_rows >= 0 else 0 + + # Pack agents - the specialized sub-agents coordinated by Pack Leader PACK_AGENT_NAMES = frozenset( [ @@ -410,6 +428,7 @@ def get_config_keys(): "message_limit", "allow_recursion", "subagent_recursion_limit", + "subagent_panel_max_rows", "auto_save_session", "max_saved_sessions", "http2", diff --git a/code_puppy/messaging/bar_painters.py b/code_puppy/messaging/bar_painters.py index 18160bb29..98a0be369 100644 --- a/code_puppy/messaging/bar_painters.py +++ b/code_puppy/messaging/bar_painters.py @@ -161,11 +161,11 @@ def _panel_row_budget(self) -> int: Mirrors :meth:`_visible_popup_lines`' "prompt viewport WINS" rule: the top margin, prompt, popup, and status always keep their rows, plus one scroll row for the transcript region. Whatever height is - left is the panel's budget — so a big sub-agent swarm is shown as - tall as the terminal allows instead of overflowing past - ``rows - 1`` and tripping the ``rows < reserved + 1`` dormancy - guard in :meth:`_establish` (which would blank the panel, prompt, - AND status all at once). + left is the panel's viewport budget. An optional user ceiling can + reduce it further, but can never override terminal safety. This keeps + a big sub-agent swarm from overflowing past ``rows - 1`` and tripping + the ``rows < reserved + 1`` dormancy guard in :meth:`_establish` + (which would blank the panel, prompt, AND status all at once). """ rows = self._rows if self._rows > 0 else 24 non_panel = ( @@ -175,7 +175,11 @@ def _panel_row_budget(self) -> int: + self._visible_popup_slack() + (1 if self._status_visible() else 0) ) - return max(0, rows - 1 - non_panel) + viewport_budget = max(0, rows - 1 - non_panel) + configured_max = self._panel_max_rows + if configured_max > 0: + return min(viewport_budget, configured_max) + return viewport_budget def _visible_panel_lines(self) -> list: """Panel rows to paint — popup takes precedence while open, and the diff --git a/code_puppy/messaging/bottom_bar.py b/code_puppy/messaging/bottom_bar.py index 3f0155637..1999b0e0b 100644 --- a/code_puppy/messaging/bottom_bar.py +++ b/code_puppy/messaging/bottom_bar.py @@ -106,6 +106,16 @@ SizeProvider = Callable[[], Tuple[int, int]] +def _configured_panel_max_rows() -> int: + """Load the panel ceiling without making config import this UI module.""" + try: + from code_puppy.config import get_subagent_panel_max_rows + + return get_subagent_panel_max_rows() + except Exception: + return 0 + + # ============================================================================= # BottomBar # ============================================================================= @@ -135,6 +145,10 @@ def __init__( self._status_prefix = "" # animated spinner slot (puppy_spinner) self._status_suffix = "" # trailing slot (steer_queue's '(N queued)') self._panel_lines: list[str] = [] + # 0 = automatic (terminal-height budget only). Keep the resolved value + # in memory: panel animation repaints several times per second, so the + # painter must not re-read puppy.cfg on every frame. + self._panel_max_rows = _configured_panel_max_rows() self._popup_lines: list[str] = [] # completion popup (over panel) self._popup_selected = -1 # Blank rows held below the prompt after the popup shrinks/closes @@ -240,6 +254,19 @@ def set_status_suffix(self, text: str) -> None: self._status_suffix = text or "" self._sync_reserved(self._status_seq) + def set_panel_max_rows(self, max_rows: int) -> None: + """Set the live panel ceiling; ``0`` keeps automatic sizing. + + The terminal-height budget still wins when it is smaller. Updating the + value repaints immediately without dropping any raw panel state. + """ + normalized = max(0, int(max_rows)) + with self._lock: + if normalized == self._panel_max_rows: + return + self._panel_max_rows = normalized + self._sync_reserved(self._panel_seq) + def set_panel_lines(self, lines: Optional[list]) -> None: """Set the sub-agent panel rows (above the status row). diff --git a/code_puppy/plugins/subagent_panel/README.md b/code_puppy/plugins/subagent_panel/README.md index aca15ad4f..1628bbf02 100644 --- a/code_puppy/plugins/subagent_panel/README.md +++ b/code_puppy/plugins/subagent_panel/README.md @@ -16,11 +16,11 @@ Milo is thinking... ( o ) - Line 2 is a single-char animated spinner + `mm:ss` elapsed + the current activity, color-coded: **yellow** = calling a tool, **magenta** = thinking, **green** = writing the response. -- Parallel sub-agents stack, each with its own live row. There is no arbitrary - cap: every tracked agent renders as long as it fits. The row count is only - clamped to the terminal height (the prompt + status always keep their rows) -- - if a swarm is taller than the viewport, the rows that don't fit collapse into a - single `… +N more` summary rather than blanking the whole bar. +- Parallel sub-agents stack, each with its own live row. By default every + tracked agent renders as long as it fits. Users may set an optional row ceiling; + terminal height still takes precedence so the prompt + status always keep their + rows. If a swarm exceeds either limit, hidden rows collapse into a single + `… +N more` summary rather than blanking the whole bar. - **Nested sub-agents render as a true tree.** A sub-agent that itself calls `invoke_agent` is shown indented under its parent. Only top-level (depth-0) agents get the full two-line `INVOKE AGENT` banner; deeper agents collapse to @@ -79,13 +79,18 @@ not starve the steer overlay's event-loop continuations. ## Config -Runtime toggle: +Runtime controls: ```text /set subagent_panel off /set subagent_panel on +/set subagent_panel_max_rows 8 +/set subagent_panel_max_rows 0 # automatic; show every row that fits ``` +The row ceiling takes effect immediately. It never overrides the terminal-height +safety clamp. + Environment startup hard-disable: | Var | Default | Meaning | diff --git a/tests/command_line/test_config_apply.py b/tests/command_line/test_config_apply.py index 2b18d517d..2221e9de7 100644 --- a/tests/command_line/test_config_apply.py +++ b/tests/command_line/test_config_apply.py @@ -29,6 +29,14 @@ def test_model_key_clears_both_caches(self): mock_reset.assert_called_once() mock_clear.assert_called_once() + def test_panel_row_ceiling_refreshes_live_bottom_bar(self): + with ( + patch("code_puppy.config.get_subagent_panel_max_rows", return_value=8), + patch("code_puppy.messaging.bottom_bar.get_bottom_bar") as mock_bar, + ): + invalidate_post_write_caches("subagent_panel_max_rows") + mock_bar.return_value.set_panel_max_rows.assert_called_once_with(8) + def test_non_model_key_is_noop(self): with ( patch("code_puppy.config.reset_session_model") as mock_reset, diff --git a/tests/command_line/test_set_menu.py b/tests/command_line/test_set_menu.py index a875e7359..56cbfb3cd 100644 --- a/tests/command_line/test_set_menu.py +++ b/tests/command_line/test_set_menu.py @@ -59,6 +59,19 @@ def test_model_settings_only_keys_are_rejected(self, key): assert "/model_settings" in (result.error or "") mock_set.assert_not_called() + @pytest.mark.parametrize("value", ["many", "-1"]) + def test_subagent_panel_max_rows_rejects_invalid_values(self, value): + with patch("code_puppy.config.set_config_value") as mock_set: + result = apply_setting("subagent_panel_max_rows", value, reload_agent=False) + assert result.ok is False + assert "0 or a positive integer" in (result.error or "") + mock_set.assert_not_called() + + def test_subagent_panel_max_rows_normalizes_valid_value(self): + result = apply_setting("subagent_panel_max_rows", " 08 ", reload_agent=False) + assert result.ok is True + assert result.value_after == "8" + def test_cancel_agent_key_invalid_returns_error(self): with patch("code_puppy.config.set_config_value") as mock_set: result = apply_setting("cancel_agent_key", "ctrl+x") diff --git a/tests/command_line/test_set_menu_values.py b/tests/command_line/test_set_menu_values.py index ced6f56ad..695a95e45 100644 --- a/tests/command_line/test_set_menu_values.py +++ b/tests/command_line/test_set_menu_values.py @@ -145,6 +145,13 @@ def test_subagent_recursion_limit_is_curated(self): assert setting.type_hint == "int" assert setting.effective_getter is not None + def test_subagent_panel_max_rows_is_curated(self): + settings = {s.key: s for _, s in iter_curated_settings()} + setting = settings["subagent_panel_max_rows"] + assert setting.type_hint == "int" + assert setting.effective_getter is not None + assert setting.effective_getter() == 0 + def test_is_sensitive_key_for_puppy_token(self): assert is_sensitive_key("puppy_token") is True assert is_sensitive_key("yolo_mode") is False diff --git a/tests/messaging/test_bottom_bar.py b/tests/messaging/test_bottom_bar.py index 704427332..fceaaaab1 100644 --- a/tests/messaging/test_bottom_bar.py +++ b/tests/messaging/test_bottom_bar.py @@ -662,6 +662,48 @@ def test_panel_all_rows_render_when_they_fit(bar, tty): assert "more" not in out # no overflow summary when everything fits +def test_panel_user_ceiling_clamps_rows_without_losing_raw_state(bar, tty): + lines = [f"agent-{i}" for i in range(6)] + bar.start() + bar.set_panel_max_rows(4) + drain(tty) + bar.set_panel_lines(lines) + out = written(tty) + + assert "agent-0" in out and "agent-1" in out and "agent-2" in out + assert "agent-3" not in out + assert "+3 more" in out + assert bar.get_panel_lines() == lines + + +def test_panel_zero_ceiling_restores_automatic_sizing(bar, tty): + lines = [f"agent-{i}" for i in range(6)] + bar.start() + bar.set_panel_max_rows(2) + bar.set_panel_lines(lines) + drain(tty) + + bar.set_panel_max_rows(0) + out = written(tty) + + for line in lines: + assert line in out + assert "more" not in out + + +def test_terminal_height_wins_over_larger_user_ceiling(tty): + bar = BottomBar(stream=tty, get_size=lambda: (80, 6)) + bar.set_panel_max_rows(12) + bar.start() + drain(tty) + bar.set_panel_lines(["a", "b", "c", "d"]) + + out = written(tty) + assert "+2 more" in out + assert bar._panel_row_budget() == 3 + bar.stop() + + def test_teardown_clears_panel_rows_too(bar, tty): bar.start() bar.set_panel_lines(["a", "b"]) diff --git a/tests/messaging/test_inline_bar.py b/tests/messaging/test_inline_bar.py index 41e9d9c45..eac320851 100644 --- a/tests/messaging/test_inline_bar.py +++ b/tests/messaging/test_inline_bar.py @@ -128,6 +128,19 @@ def test_inline_panel_clamps_to_viewport_with_overflow(): assert bar.get_panel_lines() == lines +def test_inline_panel_honors_user_ceiling(): + bar = InlineBottomBar(stream=FakeTTY(), get_size=lambda: (80, 24)) + lines = [f"agent-{index}" for index in range(6)] + + bar.set_panel_max_rows(3) + bar.start() + bar.set_panel_lines(lines) + rendered = bar._inline_lines() + + assert rendered[:3] == ["agent-0", "agent-1", "… +4 more"] + assert bar.get_panel_lines() == lines + + def test_spinner_tick_repaints_in_place_without_growing_block(): """A status-prefix tick (the 5fps puppy) must erase and repaint the same number of rows -- never leaving extra lines behind.""" diff --git a/tests/test_config.py b/tests/test_config.py index 5a60589af..6dcad416d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -335,6 +335,7 @@ def test_get_config_keys_with_existing_keys( "retry_main_strategy", "retry_subagent_max_attempts", "retry_subagent_strategy", + "subagent_panel_max_rows", "subagent_recursion_limit", "summarization_model", "suppress_directory_listing", @@ -403,6 +404,7 @@ def test_get_config_keys_empty_config( "retry_main_strategy", "retry_subagent_max_attempts", "retry_subagent_strategy", + "subagent_panel_max_rows", "subagent_recursion_limit", "summarization_model", "suppress_directory_listing", diff --git a/tests/test_config_full_coverage.py b/tests/test_config_full_coverage.py index 02d51dec7..7eddbe4dc 100644 --- a/tests/test_config_full_coverage.py +++ b/tests/test_config_full_coverage.py @@ -39,6 +39,18 @@ def test_get_subagent_verbose_true(self): cp_config.set_config_value("subagent_verbose", "true") assert cp_config.get_subagent_verbose() is True + def test_get_subagent_panel_max_rows_defaults_to_automatic(self): + assert cp_config.get_subagent_panel_max_rows() == 0 + + def test_get_subagent_panel_max_rows_accepts_non_negative_integer(self): + cp_config.set_config_value("subagent_panel_max_rows", " 8 ") + assert cp_config.get_subagent_panel_max_rows() == 8 + + def test_get_subagent_panel_max_rows_rejects_bad_persisted_values(self): + for value in ("many", "-1"): + cp_config.set_config_value("subagent_panel_max_rows", value) + assert cp_config.get_subagent_panel_max_rows() == 0 + def test_get_pack_agents_enabled_false(self): cp_config.set_config_value("enable_pack_agents", "false") assert cp_config.get_pack_agents_enabled() is False