Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion code_puppy/command_line/config_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions code_puppy/command_line/set_menu_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions code_puppy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 10 additions & 6 deletions code_puppy/messaging/bar_painters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions code_puppy/messaging/bottom_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =============================================================================
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand Down
17 changes: 11 additions & 6 deletions code_puppy/plugins/subagent_panel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
8 changes: 8 additions & 0 deletions tests/command_line/test_config_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions tests/command_line/test_set_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
7 changes: 7 additions & 0 deletions tests/command_line/test_set_menu_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions tests/messaging/test_bottom_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
13 changes: 13 additions & 0 deletions tests/messaging/test_inline_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
2 changes: 2 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions tests/test_config_full_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading